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,825 @@
1
+ """
2
+ JSON-based wire protocol for async daemon communication.
3
+
4
+ This module implements a length-prefixed JSON message protocol for reliable
5
+ communication over TCP streams. It provides:
6
+
7
+ - Message framing with 4-byte big-endian length prefix
8
+ - Message type enumeration for all daemon operations
9
+ - Base message structure with common fields
10
+ - Encode/decode functions for serialization
11
+ - Protocol constants for buffer sizes and timeouts
12
+
13
+ Wire Format:
14
+ [4 bytes: message length (big-endian uint32)][N bytes: JSON payload]
15
+
16
+ Example message structure:
17
+ {
18
+ "type": "lock_acquire",
19
+ "timestamp": 1234567890.123,
20
+ "request_id": "req_abc123",
21
+ "payload": { ... }
22
+ }
23
+ """
24
+
25
+ import json
26
+ import struct
27
+ import time
28
+ import uuid
29
+ from dataclasses import dataclass, field
30
+ from enum import Enum
31
+ from typing import Any
32
+
33
+ from fbuild.daemon.messages import (
34
+ BuildRequest,
35
+ ClientConnectRequest,
36
+ ClientDisconnectRequest,
37
+ ClientHeartbeatRequest,
38
+ ClientResponse,
39
+ DaemonStatus,
40
+ DeployRequest,
41
+ FirmwareQueryRequest,
42
+ FirmwareQueryResponse,
43
+ FirmwareRecordRequest,
44
+ InstallDependenciesRequest,
45
+ LockAcquireRequest,
46
+ LockReleaseRequest,
47
+ LockResponse,
48
+ LockStatusRequest,
49
+ MonitorRequest,
50
+ SerialAttachRequest,
51
+ SerialBufferRequest,
52
+ SerialDetachRequest,
53
+ SerialSessionResponse,
54
+ SerialWriteRequest,
55
+ )
56
+
57
+ # =============================================================================
58
+ # Protocol Constants
59
+ # =============================================================================
60
+
61
+ # Message framing
62
+ LENGTH_PREFIX_SIZE = 4 # 4 bytes for big-endian uint32 length prefix
63
+ LENGTH_PREFIX_FORMAT = ">I" # struct format for big-endian unsigned int
64
+ MAX_MESSAGE_SIZE = 16 * 1024 * 1024 # 16 MB maximum message size
65
+
66
+ # Buffer sizes
67
+ DEFAULT_READ_BUFFER_SIZE = 65536 # 64 KB read buffer
68
+ DEFAULT_WRITE_BUFFER_SIZE = 65536 # 64 KB write buffer
69
+
70
+ # Timeouts (in seconds)
71
+ DEFAULT_CONNECT_TIMEOUT = 10.0 # Connection establishment timeout
72
+ DEFAULT_READ_TIMEOUT = 30.0 # Read operation timeout
73
+ DEFAULT_WRITE_TIMEOUT = 10.0 # Write operation timeout
74
+ DEFAULT_RESPONSE_TIMEOUT = 60.0 # Time to wait for response
75
+ HEARTBEAT_INTERVAL = 10.0 # Interval between heartbeat messages
76
+ HEARTBEAT_TIMEOUT = 30.0 # Time before considering connection dead
77
+
78
+ # Retry settings
79
+ MAX_RETRY_ATTEMPTS = 3
80
+ RETRY_BACKOFF_BASE = 0.5 # Base delay for exponential backoff
81
+
82
+
83
+ # =============================================================================
84
+ # Message Types
85
+ # =============================================================================
86
+
87
+
88
+ class MessageType(Enum):
89
+ """Enumeration of all protocol message types.
90
+
91
+ Message types are grouped by functionality:
92
+ - Connection management: connect, disconnect, heartbeat
93
+ - Build operations: build, deploy, monitor, install_deps
94
+ - Lock management: lock_acquire, lock_release, lock_status
95
+ - Firmware ledger: firmware_query, firmware_record
96
+ - Serial sessions: serial_attach, serial_detach, serial_write, serial_buffer
97
+ - Status and responses: status, response, error
98
+ """
99
+
100
+ # Connection management
101
+ CONNECT = "connect"
102
+ DISCONNECT = "disconnect"
103
+ HEARTBEAT = "heartbeat"
104
+
105
+ # Build operations
106
+ BUILD = "build"
107
+ DEPLOY = "deploy"
108
+ MONITOR = "monitor"
109
+ INSTALL_DEPS = "install_deps"
110
+
111
+ # Lock management
112
+ LOCK_ACQUIRE = "lock_acquire"
113
+ LOCK_RELEASE = "lock_release"
114
+ LOCK_STATUS = "lock_status"
115
+
116
+ # Firmware ledger
117
+ FIRMWARE_QUERY = "firmware_query"
118
+ FIRMWARE_RECORD = "firmware_record"
119
+
120
+ # Serial sessions
121
+ SERIAL_ATTACH = "serial_attach"
122
+ SERIAL_DETACH = "serial_detach"
123
+ SERIAL_WRITE = "serial_write"
124
+ SERIAL_BUFFER = "serial_buffer"
125
+
126
+ # Status and responses
127
+ STATUS = "status"
128
+ RESPONSE = "response"
129
+ ERROR = "error"
130
+
131
+ # Acknowledgments
132
+ ACK = "ack"
133
+ NACK = "nack"
134
+
135
+ @classmethod
136
+ def from_string(cls, value: str) -> "MessageType":
137
+ """Convert string to MessageType.
138
+
139
+ Args:
140
+ value: String representation of message type.
141
+
142
+ Returns:
143
+ Corresponding MessageType enum value.
144
+
145
+ Raises:
146
+ ValueError: If value does not match any known message type.
147
+ """
148
+ try:
149
+ return cls(value)
150
+ except ValueError as e:
151
+ raise ValueError(f"Unknown message type: {value}") from e
152
+
153
+
154
+ # =============================================================================
155
+ # Base Message Class
156
+ # =============================================================================
157
+
158
+
159
+ @dataclass
160
+ class ProtocolMessage:
161
+ """Base protocol message with common fields.
162
+
163
+ All messages in the protocol share these common fields:
164
+ - type: The message type from MessageType enum
165
+ - timestamp: Unix timestamp when message was created
166
+ - request_id: Unique identifier for request/response correlation
167
+ - payload: Message-specific data
168
+
169
+ Attributes:
170
+ type: Message type identifier.
171
+ timestamp: Unix timestamp of message creation.
172
+ request_id: Unique ID for correlating requests and responses.
173
+ payload: Message-specific payload data.
174
+ """
175
+
176
+ type: MessageType
177
+ timestamp: float = field(default_factory=time.time)
178
+ request_id: str = field(default_factory=lambda: f"req_{uuid.uuid4().hex[:12]}")
179
+ payload: dict[str, Any] = field(default_factory=dict)
180
+
181
+ def to_dict(self) -> dict[str, Any]:
182
+ """Convert message to dictionary for JSON serialization.
183
+
184
+ Returns:
185
+ Dictionary representation of the message.
186
+ """
187
+ return {
188
+ "type": self.type.value,
189
+ "timestamp": self.timestamp,
190
+ "request_id": self.request_id,
191
+ "payload": self.payload,
192
+ }
193
+
194
+ @classmethod
195
+ def from_dict(cls, data: dict[str, Any]) -> "ProtocolMessage":
196
+ """Create ProtocolMessage from dictionary.
197
+
198
+ Args:
199
+ data: Dictionary containing message data.
200
+
201
+ Returns:
202
+ ProtocolMessage instance.
203
+
204
+ Raises:
205
+ KeyError: If required fields are missing.
206
+ ValueError: If message type is invalid.
207
+ """
208
+ return cls(
209
+ type=MessageType.from_string(data["type"]),
210
+ timestamp=data.get("timestamp", time.time()),
211
+ request_id=data.get("request_id", f"req_{uuid.uuid4().hex[:12]}"),
212
+ payload=data.get("payload", {}),
213
+ )
214
+
215
+
216
+ # =============================================================================
217
+ # Response Message
218
+ # =============================================================================
219
+
220
+
221
+ @dataclass
222
+ class ResponseMessage:
223
+ """Response message for request/response correlation.
224
+
225
+ Attributes:
226
+ type: Always MessageType.RESPONSE.
227
+ timestamp: Unix timestamp of response creation.
228
+ request_id: ID of the request this responds to.
229
+ success: Whether the operation succeeded.
230
+ message: Human-readable status message.
231
+ payload: Response-specific data.
232
+ error_code: Optional error code if success is False.
233
+ """
234
+
235
+ request_id: str
236
+ success: bool
237
+ message: str = ""
238
+ timestamp: float = field(default_factory=time.time)
239
+ payload: dict[str, Any] = field(default_factory=dict)
240
+ error_code: str | None = None
241
+
242
+ def to_dict(self) -> dict[str, Any]:
243
+ """Convert response to dictionary for JSON serialization.
244
+
245
+ Returns:
246
+ Dictionary representation of the response.
247
+ """
248
+ result = {
249
+ "type": MessageType.RESPONSE.value,
250
+ "timestamp": self.timestamp,
251
+ "request_id": self.request_id,
252
+ "success": self.success,
253
+ "message": self.message,
254
+ "payload": self.payload,
255
+ }
256
+ if self.error_code is not None:
257
+ result["error_code"] = self.error_code
258
+ return result
259
+
260
+ @classmethod
261
+ def from_dict(cls, data: dict[str, Any]) -> "ResponseMessage":
262
+ """Create ResponseMessage from dictionary.
263
+
264
+ Args:
265
+ data: Dictionary containing response data.
266
+
267
+ Returns:
268
+ ResponseMessage instance.
269
+ """
270
+ return cls(
271
+ request_id=data["request_id"],
272
+ success=data["success"],
273
+ message=data.get("message", ""),
274
+ timestamp=data.get("timestamp", time.time()),
275
+ payload=data.get("payload", {}),
276
+ error_code=data.get("error_code"),
277
+ )
278
+
279
+
280
+ # =============================================================================
281
+ # Error Message
282
+ # =============================================================================
283
+
284
+
285
+ @dataclass
286
+ class ErrorMessage:
287
+ """Error message for protocol-level errors.
288
+
289
+ Attributes:
290
+ request_id: ID of the request that caused the error (if known).
291
+ error_code: Machine-readable error code.
292
+ error_message: Human-readable error description.
293
+ timestamp: Unix timestamp of error creation.
294
+ details: Additional error details.
295
+ """
296
+
297
+ error_code: str
298
+ error_message: str
299
+ request_id: str | None = None
300
+ timestamp: float = field(default_factory=time.time)
301
+ details: dict[str, Any] = field(default_factory=dict)
302
+
303
+ def to_dict(self) -> dict[str, Any]:
304
+ """Convert error to dictionary for JSON serialization.
305
+
306
+ Returns:
307
+ Dictionary representation of the error.
308
+ """
309
+ result = {
310
+ "type": MessageType.ERROR.value,
311
+ "timestamp": self.timestamp,
312
+ "error_code": self.error_code,
313
+ "error_message": self.error_message,
314
+ "details": self.details,
315
+ }
316
+ if self.request_id is not None:
317
+ result["request_id"] = self.request_id
318
+ return result
319
+
320
+ @classmethod
321
+ def from_dict(cls, data: dict[str, Any]) -> "ErrorMessage":
322
+ """Create ErrorMessage from dictionary.
323
+
324
+ Args:
325
+ data: Dictionary containing error data.
326
+
327
+ Returns:
328
+ ErrorMessage instance.
329
+ """
330
+ return cls(
331
+ error_code=data["error_code"],
332
+ error_message=data["error_message"],
333
+ request_id=data.get("request_id"),
334
+ timestamp=data.get("timestamp", time.time()),
335
+ details=data.get("details", {}),
336
+ )
337
+
338
+
339
+ # =============================================================================
340
+ # Protocol Error Codes
341
+ # =============================================================================
342
+
343
+
344
+ class ProtocolErrorCode:
345
+ """Standard protocol error codes."""
346
+
347
+ # Framing errors
348
+ INVALID_LENGTH = "INVALID_LENGTH"
349
+ MESSAGE_TOO_LARGE = "MESSAGE_TOO_LARGE"
350
+ INCOMPLETE_MESSAGE = "INCOMPLETE_MESSAGE"
351
+
352
+ # Parsing errors
353
+ INVALID_JSON = "INVALID_JSON"
354
+ MISSING_FIELD = "MISSING_FIELD"
355
+ INVALID_MESSAGE_TYPE = "INVALID_MESSAGE_TYPE"
356
+ INVALID_PAYLOAD = "INVALID_PAYLOAD"
357
+
358
+ # Connection errors
359
+ CONNECTION_CLOSED = "CONNECTION_CLOSED"
360
+ CONNECTION_TIMEOUT = "CONNECTION_TIMEOUT"
361
+ CONNECTION_REFUSED = "CONNECTION_REFUSED"
362
+
363
+ # Request errors
364
+ UNKNOWN_REQUEST = "UNKNOWN_REQUEST"
365
+ DUPLICATE_REQUEST = "DUPLICATE_REQUEST"
366
+ REQUEST_TIMEOUT = "REQUEST_TIMEOUT"
367
+
368
+ # Internal errors
369
+ INTERNAL_ERROR = "INTERNAL_ERROR"
370
+ NOT_IMPLEMENTED = "NOT_IMPLEMENTED"
371
+
372
+
373
+ # =============================================================================
374
+ # Protocol Exceptions
375
+ # =============================================================================
376
+
377
+
378
+ class ProtocolError(Exception):
379
+ """Base exception for protocol errors.
380
+
381
+ Attributes:
382
+ error_code: Machine-readable error code.
383
+ message: Human-readable error description.
384
+ request_id: ID of the related request (if known).
385
+ """
386
+
387
+ def __init__(
388
+ self,
389
+ error_code: str,
390
+ message: str,
391
+ request_id: str | None = None,
392
+ ) -> None:
393
+ """Initialize ProtocolError.
394
+
395
+ Args:
396
+ error_code: Machine-readable error code.
397
+ message: Human-readable error description.
398
+ request_id: ID of the related request (if known).
399
+ """
400
+ super().__init__(message)
401
+ self.error_code = error_code
402
+ self.request_id = request_id
403
+
404
+ def to_error_message(self) -> ErrorMessage:
405
+ """Convert exception to ErrorMessage.
406
+
407
+ Returns:
408
+ ErrorMessage representing this exception.
409
+ """
410
+ return ErrorMessage(
411
+ error_code=self.error_code,
412
+ error_message=str(self),
413
+ request_id=self.request_id,
414
+ )
415
+
416
+
417
+ class FramingError(ProtocolError):
418
+ """Error in message framing (length prefix issues)."""
419
+
420
+ def __init__(self, message: str) -> None:
421
+ """Initialize FramingError.
422
+
423
+ Args:
424
+ message: Human-readable error description.
425
+ """
426
+ super().__init__(ProtocolErrorCode.INVALID_LENGTH, message)
427
+
428
+
429
+ class ParseError(ProtocolError):
430
+ """Error parsing message content."""
431
+
432
+ def __init__(self, message: str, request_id: str | None = None) -> None:
433
+ """Initialize ParseError.
434
+
435
+ Args:
436
+ message: Human-readable error description.
437
+ request_id: ID of the related request (if known).
438
+ """
439
+ super().__init__(ProtocolErrorCode.INVALID_JSON, message, request_id)
440
+
441
+
442
+ class MessageTooLargeError(ProtocolError):
443
+ """Message exceeds maximum allowed size."""
444
+
445
+ def __init__(self, size: int, max_size: int = MAX_MESSAGE_SIZE) -> None:
446
+ """Initialize MessageTooLargeError.
447
+
448
+ Args:
449
+ size: Actual message size in bytes.
450
+ max_size: Maximum allowed size in bytes.
451
+ """
452
+ super().__init__(
453
+ ProtocolErrorCode.MESSAGE_TOO_LARGE,
454
+ f"Message size {size} exceeds maximum {max_size}",
455
+ )
456
+ self.size = size
457
+ self.max_size = max_size
458
+
459
+
460
+ # =============================================================================
461
+ # Encode/Decode Functions
462
+ # =============================================================================
463
+
464
+
465
+ def encode_message(message: dict[str, Any]) -> bytes:
466
+ """Encode a message dictionary to wire format.
467
+
468
+ Wire format: [4 bytes length prefix][JSON payload]
469
+
470
+ Args:
471
+ message: Message dictionary to encode.
472
+
473
+ Returns:
474
+ Bytes containing length-prefixed JSON message.
475
+
476
+ Raises:
477
+ MessageTooLargeError: If encoded message exceeds MAX_MESSAGE_SIZE.
478
+ """
479
+ # Serialize to JSON
480
+ json_bytes = json.dumps(message, separators=(",", ":")).encode("utf-8")
481
+
482
+ # Check size limit
483
+ if len(json_bytes) > MAX_MESSAGE_SIZE:
484
+ raise MessageTooLargeError(len(json_bytes))
485
+
486
+ # Create length prefix
487
+ length_prefix = struct.pack(LENGTH_PREFIX_FORMAT, len(json_bytes))
488
+
489
+ return length_prefix + json_bytes
490
+
491
+
492
+ def encode_protocol_message(msg: ProtocolMessage | ResponseMessage | ErrorMessage) -> bytes:
493
+ """Encode a protocol message object to wire format.
494
+
495
+ Args:
496
+ msg: Protocol message to encode.
497
+
498
+ Returns:
499
+ Bytes containing length-prefixed JSON message.
500
+
501
+ Raises:
502
+ MessageTooLargeError: If encoded message exceeds MAX_MESSAGE_SIZE.
503
+ """
504
+ return encode_message(msg.to_dict())
505
+
506
+
507
+ def decode_length_prefix(data: bytes) -> int:
508
+ """Decode the length prefix from message header.
509
+
510
+ Args:
511
+ data: Bytes containing the length prefix (must be LENGTH_PREFIX_SIZE bytes).
512
+
513
+ Returns:
514
+ Message length in bytes.
515
+
516
+ Raises:
517
+ FramingError: If data is too short or contains invalid length.
518
+ """
519
+ if len(data) < LENGTH_PREFIX_SIZE:
520
+ raise FramingError(f"Incomplete length prefix: got {len(data)} bytes, need {LENGTH_PREFIX_SIZE}")
521
+
522
+ length = struct.unpack(LENGTH_PREFIX_FORMAT, data[:LENGTH_PREFIX_SIZE])[0]
523
+
524
+ if length > MAX_MESSAGE_SIZE:
525
+ raise MessageTooLargeError(length)
526
+
527
+ return length
528
+
529
+
530
+ def decode_message(data: bytes) -> dict[str, Any]:
531
+ """Decode a JSON message from bytes (without length prefix).
532
+
533
+ Args:
534
+ data: JSON-encoded message bytes.
535
+
536
+ Returns:
537
+ Decoded message dictionary.
538
+
539
+ Raises:
540
+ ParseError: If JSON parsing fails.
541
+ """
542
+ try:
543
+ return json.loads(data.decode("utf-8"))
544
+ except json.JSONDecodeError as e:
545
+ raise ParseError(f"Invalid JSON: {e}") from e
546
+ except UnicodeDecodeError as e:
547
+ raise ParseError(f"Invalid UTF-8 encoding: {e}") from e
548
+
549
+
550
+ def decode_framed_message(data: bytes) -> tuple[dict[str, Any], int]:
551
+ """Decode a length-prefixed message from bytes.
552
+
553
+ This function handles the complete wire format including length prefix.
554
+
555
+ Args:
556
+ data: Bytes containing length prefix and JSON payload.
557
+
558
+ Returns:
559
+ Tuple of (decoded message dict, total bytes consumed).
560
+
561
+ Raises:
562
+ FramingError: If data is too short for length prefix.
563
+ MessageTooLargeError: If message exceeds size limit.
564
+ ParseError: If JSON parsing fails.
565
+ """
566
+ if len(data) < LENGTH_PREFIX_SIZE:
567
+ raise FramingError(f"Incomplete data: got {len(data)} bytes, need at least {LENGTH_PREFIX_SIZE}")
568
+
569
+ # Decode length
570
+ length = decode_length_prefix(data)
571
+
572
+ # Check we have complete message
573
+ total_size = LENGTH_PREFIX_SIZE + length
574
+ if len(data) < total_size:
575
+ raise FramingError(f"Incomplete message: got {len(data)} bytes, need {total_size}")
576
+
577
+ # Decode JSON payload
578
+ json_data = data[LENGTH_PREFIX_SIZE:total_size]
579
+ message = decode_message(json_data)
580
+
581
+ return message, total_size
582
+
583
+
584
+ # =============================================================================
585
+ # Message Factory Functions
586
+ # =============================================================================
587
+
588
+
589
+ def create_request_message(
590
+ message_type: MessageType,
591
+ payload: dict[str, Any],
592
+ request_id: str | None = None,
593
+ ) -> ProtocolMessage:
594
+ """Create a new request message.
595
+
596
+ Args:
597
+ message_type: Type of the request.
598
+ payload: Request-specific payload data.
599
+ request_id: Optional request ID (generated if not provided).
600
+
601
+ Returns:
602
+ ProtocolMessage instance.
603
+ """
604
+ return ProtocolMessage(
605
+ type=message_type,
606
+ payload=payload,
607
+ request_id=request_id or f"req_{uuid.uuid4().hex[:12]}",
608
+ )
609
+
610
+
611
+ def create_response_message(
612
+ request_id: str,
613
+ success: bool,
614
+ message: str = "",
615
+ payload: dict[str, Any] | None = None,
616
+ error_code: str | None = None,
617
+ ) -> ResponseMessage:
618
+ """Create a response message for a request.
619
+
620
+ Args:
621
+ request_id: ID of the request being responded to.
622
+ success: Whether the operation succeeded.
623
+ message: Human-readable status message.
624
+ payload: Response-specific payload data.
625
+ error_code: Error code if success is False.
626
+
627
+ Returns:
628
+ ResponseMessage instance.
629
+ """
630
+ return ResponseMessage(
631
+ request_id=request_id,
632
+ success=success,
633
+ message=message,
634
+ payload=payload or {},
635
+ error_code=error_code,
636
+ )
637
+
638
+
639
+ def create_error_message(
640
+ error_code: str,
641
+ error_message: str,
642
+ request_id: str | None = None,
643
+ details: dict[str, Any] | None = None,
644
+ ) -> ErrorMessage:
645
+ """Create an error message.
646
+
647
+ Args:
648
+ error_code: Machine-readable error code.
649
+ error_message: Human-readable error description.
650
+ request_id: ID of the related request (if known).
651
+ details: Additional error details.
652
+
653
+ Returns:
654
+ ErrorMessage instance.
655
+ """
656
+ return ErrorMessage(
657
+ error_code=error_code,
658
+ error_message=error_message,
659
+ request_id=request_id,
660
+ details=details or {},
661
+ )
662
+
663
+
664
+ # =============================================================================
665
+ # Message Type to Payload Mapping
666
+ # =============================================================================
667
+
668
+
669
+ # Maps message types to their corresponding request/response classes from messages.py
670
+ MESSAGE_TYPE_MAP: dict[MessageType, type] = {
671
+ MessageType.CONNECT: ClientConnectRequest,
672
+ MessageType.DISCONNECT: ClientDisconnectRequest,
673
+ MessageType.HEARTBEAT: ClientHeartbeatRequest,
674
+ MessageType.BUILD: BuildRequest,
675
+ MessageType.DEPLOY: DeployRequest,
676
+ MessageType.MONITOR: MonitorRequest,
677
+ MessageType.INSTALL_DEPS: InstallDependenciesRequest,
678
+ MessageType.LOCK_ACQUIRE: LockAcquireRequest,
679
+ MessageType.LOCK_RELEASE: LockReleaseRequest,
680
+ MessageType.LOCK_STATUS: LockStatusRequest,
681
+ MessageType.FIRMWARE_QUERY: FirmwareQueryRequest,
682
+ MessageType.FIRMWARE_RECORD: FirmwareRecordRequest,
683
+ MessageType.SERIAL_ATTACH: SerialAttachRequest,
684
+ MessageType.SERIAL_DETACH: SerialDetachRequest,
685
+ MessageType.SERIAL_WRITE: SerialWriteRequest,
686
+ MessageType.SERIAL_BUFFER: SerialBufferRequest,
687
+ }
688
+
689
+ RESPONSE_TYPE_MAP: dict[MessageType, type] = {
690
+ MessageType.CONNECT: ClientResponse,
691
+ MessageType.DISCONNECT: ClientResponse,
692
+ MessageType.HEARTBEAT: ClientResponse,
693
+ MessageType.STATUS: DaemonStatus,
694
+ MessageType.LOCK_ACQUIRE: LockResponse,
695
+ MessageType.LOCK_RELEASE: LockResponse,
696
+ MessageType.LOCK_STATUS: LockResponse,
697
+ MessageType.FIRMWARE_QUERY: FirmwareQueryResponse,
698
+ MessageType.SERIAL_ATTACH: SerialSessionResponse,
699
+ MessageType.SERIAL_DETACH: SerialSessionResponse,
700
+ MessageType.SERIAL_WRITE: SerialSessionResponse,
701
+ MessageType.SERIAL_BUFFER: SerialSessionResponse,
702
+ }
703
+
704
+
705
+ def parse_typed_payload(
706
+ message_type: MessageType,
707
+ payload: dict[str, Any],
708
+ ) -> Any:
709
+ """Parse a payload dictionary into a typed message object.
710
+
711
+ Uses the MESSAGE_TYPE_MAP to find the appropriate class for the
712
+ message type and creates an instance from the payload.
713
+
714
+ Args:
715
+ message_type: Type of the message.
716
+ payload: Payload dictionary to parse.
717
+
718
+ Returns:
719
+ Typed message object (e.g., BuildRequest, LockAcquireRequest).
720
+
721
+ Raises:
722
+ ValueError: If message type has no registered payload class.
723
+ KeyError: If required fields are missing from payload.
724
+ """
725
+ payload_class = MESSAGE_TYPE_MAP.get(message_type)
726
+ if payload_class is None:
727
+ raise ValueError(f"No payload class registered for message type: {message_type}")
728
+
729
+ return payload_class.from_dict(payload)
730
+
731
+
732
+ def parse_typed_response(
733
+ message_type: MessageType,
734
+ payload: dict[str, Any],
735
+ ) -> Any:
736
+ """Parse a response payload into a typed response object.
737
+
738
+ Uses the RESPONSE_TYPE_MAP to find the appropriate class for the
739
+ response type and creates an instance from the payload.
740
+
741
+ Args:
742
+ message_type: Type of the original request.
743
+ payload: Response payload dictionary to parse.
744
+
745
+ Returns:
746
+ Typed response object (e.g., LockResponse, DaemonStatus).
747
+
748
+ Raises:
749
+ ValueError: If message type has no registered response class.
750
+ KeyError: If required fields are missing from payload.
751
+ """
752
+ response_class = RESPONSE_TYPE_MAP.get(message_type)
753
+ if response_class is None:
754
+ raise ValueError(f"No response class registered for message type: {message_type}")
755
+
756
+ return response_class.from_dict(payload)
757
+
758
+
759
+ # =============================================================================
760
+ # Utility Functions
761
+ # =============================================================================
762
+
763
+
764
+ def generate_request_id() -> str:
765
+ """Generate a unique request ID.
766
+
767
+ Returns:
768
+ Unique request ID string in format "req_<hex>".
769
+ """
770
+ return f"req_{uuid.uuid4().hex[:12]}"
771
+
772
+
773
+ def is_request_type(message_type: MessageType) -> bool:
774
+ """Check if a message type is a request type.
775
+
776
+ Args:
777
+ message_type: Message type to check.
778
+
779
+ Returns:
780
+ True if the type represents a request, False otherwise.
781
+ """
782
+ return message_type in MESSAGE_TYPE_MAP
783
+
784
+
785
+ def is_response_type(message_type: MessageType) -> bool:
786
+ """Check if a message type is a response type.
787
+
788
+ Args:
789
+ message_type: Message type to check.
790
+
791
+ Returns:
792
+ True if the type represents a response.
793
+ """
794
+ return message_type in (MessageType.RESPONSE, MessageType.ACK, MessageType.NACK)
795
+
796
+
797
+ def is_error_type(message_type: MessageType) -> bool:
798
+ """Check if a message type is an error type.
799
+
800
+ Args:
801
+ message_type: Message type to check.
802
+
803
+ Returns:
804
+ True if the type represents an error.
805
+ """
806
+ return message_type == MessageType.ERROR
807
+
808
+
809
+ def get_message_type_from_dict(data: dict[str, Any]) -> MessageType:
810
+ """Extract and parse message type from a message dictionary.
811
+
812
+ Args:
813
+ data: Message dictionary.
814
+
815
+ Returns:
816
+ Parsed MessageType enum value.
817
+
818
+ Raises:
819
+ KeyError: If 'type' field is missing.
820
+ ValueError: If type value is invalid.
821
+ """
822
+ if "type" not in data:
823
+ raise KeyError("Missing 'type' field in message")
824
+
825
+ return MessageType.from_string(data["type"])