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