fbuild 1.2.8__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 (121) hide show
  1. fbuild/__init__.py +390 -0
  2. fbuild/assets/example.txt +1 -0
  3. fbuild/build/__init__.py +117 -0
  4. fbuild/build/archive_creator.py +186 -0
  5. fbuild/build/binary_generator.py +444 -0
  6. fbuild/build/build_component_factory.py +131 -0
  7. fbuild/build/build_info_generator.py +624 -0
  8. fbuild/build/build_state.py +325 -0
  9. fbuild/build/build_utils.py +93 -0
  10. fbuild/build/compilation_executor.py +422 -0
  11. fbuild/build/compiler.py +165 -0
  12. fbuild/build/compiler_avr.py +574 -0
  13. fbuild/build/configurable_compiler.py +664 -0
  14. fbuild/build/configurable_linker.py +637 -0
  15. fbuild/build/flag_builder.py +214 -0
  16. fbuild/build/library_dependency_processor.py +185 -0
  17. fbuild/build/linker.py +708 -0
  18. fbuild/build/orchestrator.py +67 -0
  19. fbuild/build/orchestrator_avr.py +651 -0
  20. fbuild/build/orchestrator_esp32.py +878 -0
  21. fbuild/build/orchestrator_rp2040.py +719 -0
  22. fbuild/build/orchestrator_stm32.py +696 -0
  23. fbuild/build/orchestrator_teensy.py +580 -0
  24. fbuild/build/source_compilation_orchestrator.py +218 -0
  25. fbuild/build/source_scanner.py +516 -0
  26. fbuild/cli.py +717 -0
  27. fbuild/cli_utils.py +314 -0
  28. fbuild/config/__init__.py +16 -0
  29. fbuild/config/board_config.py +542 -0
  30. fbuild/config/board_loader.py +92 -0
  31. fbuild/config/ini_parser.py +369 -0
  32. fbuild/config/mcu_specs.py +88 -0
  33. fbuild/daemon/__init__.py +42 -0
  34. fbuild/daemon/async_client.py +531 -0
  35. fbuild/daemon/client.py +1505 -0
  36. fbuild/daemon/compilation_queue.py +293 -0
  37. fbuild/daemon/configuration_lock.py +865 -0
  38. fbuild/daemon/daemon.py +585 -0
  39. fbuild/daemon/daemon_context.py +293 -0
  40. fbuild/daemon/error_collector.py +263 -0
  41. fbuild/daemon/file_cache.py +332 -0
  42. fbuild/daemon/firmware_ledger.py +546 -0
  43. fbuild/daemon/lock_manager.py +508 -0
  44. fbuild/daemon/logging_utils.py +149 -0
  45. fbuild/daemon/messages.py +957 -0
  46. fbuild/daemon/operation_registry.py +288 -0
  47. fbuild/daemon/port_state_manager.py +249 -0
  48. fbuild/daemon/process_tracker.py +366 -0
  49. fbuild/daemon/processors/__init__.py +18 -0
  50. fbuild/daemon/processors/build_processor.py +248 -0
  51. fbuild/daemon/processors/deploy_processor.py +664 -0
  52. fbuild/daemon/processors/install_deps_processor.py +431 -0
  53. fbuild/daemon/processors/locking_processor.py +777 -0
  54. fbuild/daemon/processors/monitor_processor.py +285 -0
  55. fbuild/daemon/request_processor.py +457 -0
  56. fbuild/daemon/shared_serial.py +819 -0
  57. fbuild/daemon/status_manager.py +238 -0
  58. fbuild/daemon/subprocess_manager.py +316 -0
  59. fbuild/deploy/__init__.py +21 -0
  60. fbuild/deploy/deployer.py +67 -0
  61. fbuild/deploy/deployer_esp32.py +310 -0
  62. fbuild/deploy/docker_utils.py +315 -0
  63. fbuild/deploy/monitor.py +519 -0
  64. fbuild/deploy/qemu_runner.py +603 -0
  65. fbuild/interrupt_utils.py +34 -0
  66. fbuild/ledger/__init__.py +52 -0
  67. fbuild/ledger/board_ledger.py +560 -0
  68. fbuild/output.py +352 -0
  69. fbuild/packages/__init__.py +66 -0
  70. fbuild/packages/archive_utils.py +1098 -0
  71. fbuild/packages/arduino_core.py +412 -0
  72. fbuild/packages/cache.py +256 -0
  73. fbuild/packages/concurrent_manager.py +510 -0
  74. fbuild/packages/downloader.py +518 -0
  75. fbuild/packages/fingerprint.py +423 -0
  76. fbuild/packages/framework_esp32.py +538 -0
  77. fbuild/packages/framework_rp2040.py +349 -0
  78. fbuild/packages/framework_stm32.py +459 -0
  79. fbuild/packages/framework_teensy.py +346 -0
  80. fbuild/packages/github_utils.py +96 -0
  81. fbuild/packages/header_trampoline_cache.py +394 -0
  82. fbuild/packages/library_compiler.py +203 -0
  83. fbuild/packages/library_manager.py +549 -0
  84. fbuild/packages/library_manager_esp32.py +725 -0
  85. fbuild/packages/package.py +163 -0
  86. fbuild/packages/platform_esp32.py +383 -0
  87. fbuild/packages/platform_rp2040.py +400 -0
  88. fbuild/packages/platform_stm32.py +581 -0
  89. fbuild/packages/platform_teensy.py +312 -0
  90. fbuild/packages/platform_utils.py +131 -0
  91. fbuild/packages/platformio_registry.py +369 -0
  92. fbuild/packages/sdk_utils.py +231 -0
  93. fbuild/packages/toolchain.py +436 -0
  94. fbuild/packages/toolchain_binaries.py +196 -0
  95. fbuild/packages/toolchain_esp32.py +489 -0
  96. fbuild/packages/toolchain_metadata.py +185 -0
  97. fbuild/packages/toolchain_rp2040.py +436 -0
  98. fbuild/packages/toolchain_stm32.py +417 -0
  99. fbuild/packages/toolchain_teensy.py +404 -0
  100. fbuild/platform_configs/esp32.json +150 -0
  101. fbuild/platform_configs/esp32c2.json +144 -0
  102. fbuild/platform_configs/esp32c3.json +143 -0
  103. fbuild/platform_configs/esp32c5.json +151 -0
  104. fbuild/platform_configs/esp32c6.json +151 -0
  105. fbuild/platform_configs/esp32p4.json +149 -0
  106. fbuild/platform_configs/esp32s3.json +151 -0
  107. fbuild/platform_configs/imxrt1062.json +56 -0
  108. fbuild/platform_configs/rp2040.json +70 -0
  109. fbuild/platform_configs/rp2350.json +76 -0
  110. fbuild/platform_configs/stm32f1.json +59 -0
  111. fbuild/platform_configs/stm32f4.json +63 -0
  112. fbuild/py.typed +0 -0
  113. fbuild-1.2.8.dist-info/METADATA +468 -0
  114. fbuild-1.2.8.dist-info/RECORD +121 -0
  115. fbuild-1.2.8.dist-info/WHEEL +5 -0
  116. fbuild-1.2.8.dist-info/entry_points.txt +5 -0
  117. fbuild-1.2.8.dist-info/licenses/LICENSE +21 -0
  118. fbuild-1.2.8.dist-info/top_level.txt +2 -0
  119. fbuild_lint/__init__.py +0 -0
  120. fbuild_lint/ruff_plugins/__init__.py +0 -0
  121. fbuild_lint/ruff_plugins/keyboard_interrupt_checker.py +158 -0
@@ -0,0 +1,777 @@
1
+ """
2
+ Locking Request Processor - Handles lock management, firmware queries, and serial session requests.
3
+
4
+ This module provides processors for the new centralized locking mechanism messages:
5
+ - Lock management (acquire, release, status)
6
+ - Firmware ledger queries (check if firmware is current)
7
+ - Serial session management (attach, detach, write, read buffer)
8
+ - Client connection management (connect, heartbeat, disconnect)
9
+ """
10
+
11
+ import base64
12
+ import logging
13
+ import time
14
+ from dataclasses import dataclass, field
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ from fbuild.daemon.messages import (
18
+ ClientConnectRequest,
19
+ ClientDisconnectRequest,
20
+ ClientHeartbeatRequest,
21
+ ClientResponse,
22
+ FirmwareQueryRequest,
23
+ FirmwareQueryResponse,
24
+ FirmwareRecordRequest,
25
+ LockAcquireRequest,
26
+ LockReleaseRequest,
27
+ LockResponse,
28
+ LockStatusRequest,
29
+ LockType,
30
+ SerialAttachRequest,
31
+ SerialBufferRequest,
32
+ SerialDetachRequest,
33
+ SerialSessionResponse,
34
+ SerialWriteRequest,
35
+ )
36
+
37
+ if TYPE_CHECKING:
38
+ from fbuild.daemon.daemon_context import DaemonContext
39
+
40
+
41
+ @dataclass
42
+ class GenericResponse:
43
+ """Generic response for operations without a specific response type."""
44
+
45
+ success: bool
46
+ message: str
47
+ data: dict[str, Any] = field(default_factory=dict)
48
+ timestamp: float = field(default_factory=time.time)
49
+
50
+ def to_dict(self) -> dict[str, Any]:
51
+ """Convert to dictionary for JSON serialization."""
52
+ return {
53
+ "success": self.success,
54
+ "message": self.message,
55
+ "data": self.data,
56
+ "timestamp": self.timestamp,
57
+ }
58
+
59
+
60
+ class LockingRequestProcessor:
61
+ """Processor for lock management requests.
62
+
63
+ Handles lock acquire, release, and status requests using the
64
+ ConfigurationLockManager from the daemon context.
65
+ """
66
+
67
+ def handle_lock_acquire(
68
+ self,
69
+ request: LockAcquireRequest,
70
+ context: "DaemonContext",
71
+ ) -> LockResponse:
72
+ """Handle a lock acquisition request.
73
+
74
+ Args:
75
+ request: Lock acquisition request
76
+ context: Daemon context with configuration lock manager
77
+
78
+ Returns:
79
+ LockResponse indicating success or failure
80
+ """
81
+ config_key = (request.project_dir, request.environment, request.port)
82
+ manager = context.configuration_lock_manager
83
+
84
+ logging.info(f"Lock acquire request: client={request.client_id}, " f"config={config_key}, type={request.lock_type.value}")
85
+
86
+ try:
87
+ if request.lock_type == LockType.EXCLUSIVE:
88
+ success = manager.acquire_exclusive(
89
+ config_key=config_key,
90
+ client_id=request.client_id,
91
+ description=request.description,
92
+ timeout=request.timeout,
93
+ )
94
+ else: # SHARED_READ
95
+ success = manager.acquire_shared_read(
96
+ config_key=config_key,
97
+ client_id=request.client_id,
98
+ description=request.description,
99
+ )
100
+
101
+ if success:
102
+ # Track resource attachment for cleanup
103
+ resource_key = f"lock:{request.project_dir}|{request.environment}|{request.port}"
104
+ context.client_manager.attach_resource(request.client_id, resource_key)
105
+
106
+ lock_status = manager.get_lock_status(config_key)
107
+ return LockResponse(
108
+ success=True,
109
+ message=f"Lock acquired ({request.lock_type.value})",
110
+ lock_state=lock_status.get("state", "unknown"),
111
+ holder_count=lock_status.get("holder_count", 0),
112
+ waiting_count=lock_status.get("waiting_count", 0),
113
+ )
114
+ else:
115
+ lock_status = manager.get_lock_status(config_key)
116
+ return LockResponse(
117
+ success=False,
118
+ message="Lock not available",
119
+ lock_state=lock_status.get("state", "unknown"),
120
+ holder_count=lock_status.get("holder_count", 0),
121
+ waiting_count=lock_status.get("waiting_count", 0),
122
+ )
123
+
124
+ except KeyboardInterrupt: # noqa: KBI002
125
+ raise
126
+ except Exception as e:
127
+ logging.error(f"Error acquiring lock: {e}")
128
+ return LockResponse(
129
+ success=False,
130
+ message=f"Error acquiring lock: {e}",
131
+ )
132
+
133
+ def handle_lock_release(
134
+ self,
135
+ request: LockReleaseRequest,
136
+ context: "DaemonContext",
137
+ ) -> LockResponse:
138
+ """Handle a lock release request.
139
+
140
+ Args:
141
+ request: Lock release request
142
+ context: Daemon context with configuration lock manager
143
+
144
+ Returns:
145
+ LockResponse indicating success or failure
146
+ """
147
+ config_key = (request.project_dir, request.environment, request.port)
148
+ manager = context.configuration_lock_manager
149
+
150
+ logging.info(f"Lock release request: client={request.client_id}, config={config_key}")
151
+
152
+ try:
153
+ success = manager.release(config_key, request.client_id)
154
+
155
+ if success:
156
+ # Remove resource tracking
157
+ resource_key = f"lock:{request.project_dir}|{request.environment}|{request.port}"
158
+ context.client_manager.detach_resource(request.client_id, resource_key)
159
+
160
+ lock_status = manager.get_lock_status(config_key)
161
+
162
+ return LockResponse(
163
+ success=success,
164
+ message="Lock released" if success else "No lock held by client",
165
+ lock_state=lock_status.get("state", "unlocked"),
166
+ holder_count=lock_status.get("holder_count", 0),
167
+ waiting_count=lock_status.get("waiting_count", 0),
168
+ )
169
+
170
+ except KeyboardInterrupt: # noqa: KBI002
171
+ raise
172
+ except Exception as e:
173
+ logging.error(f"Error releasing lock: {e}")
174
+ return LockResponse(
175
+ success=False,
176
+ message=f"Error releasing lock: {e}",
177
+ )
178
+
179
+ def handle_lock_status(
180
+ self,
181
+ request: LockStatusRequest,
182
+ context: "DaemonContext",
183
+ ) -> LockResponse:
184
+ """Handle a lock status query request.
185
+
186
+ Args:
187
+ request: Lock status request
188
+ context: Daemon context with configuration lock manager
189
+
190
+ Returns:
191
+ LockResponse with current lock state
192
+ """
193
+ config_key = (request.project_dir, request.environment, request.port)
194
+ manager = context.configuration_lock_manager
195
+
196
+ logging.debug(f"Lock status request: config={config_key}")
197
+
198
+ try:
199
+ lock_status = manager.get_lock_status(config_key)
200
+
201
+ return LockResponse(
202
+ success=True,
203
+ message="Lock status retrieved",
204
+ lock_state=lock_status.get("state", "unlocked"),
205
+ holder_count=lock_status.get("holder_count", 0),
206
+ waiting_count=lock_status.get("waiting_count", 0),
207
+ )
208
+
209
+ except KeyboardInterrupt: # noqa: KBI002
210
+ raise
211
+ except Exception as e:
212
+ logging.error(f"Error getting lock status: {e}")
213
+ return LockResponse(
214
+ success=False,
215
+ message=f"Error getting lock status: {e}",
216
+ )
217
+
218
+
219
+ class FirmwareRequestProcessor:
220
+ """Processor for firmware ledger requests.
221
+
222
+ Handles firmware query and record requests using the FirmwareLedger
223
+ from the daemon context.
224
+ """
225
+
226
+ def handle_firmware_query(
227
+ self,
228
+ request: FirmwareQueryRequest,
229
+ context: "DaemonContext",
230
+ ) -> FirmwareQueryResponse:
231
+ """Handle a firmware query request.
232
+
233
+ Checks if the current firmware on a device matches the expected
234
+ source and build flags hashes.
235
+
236
+ Args:
237
+ request: Firmware query request
238
+ context: Daemon context with firmware ledger
239
+
240
+ Returns:
241
+ FirmwareQueryResponse with firmware status
242
+ """
243
+ ledger = context.firmware_ledger
244
+
245
+ logging.debug(f"Firmware query request: port={request.port}, " f"source_hash={request.source_hash[:16]}...")
246
+
247
+ try:
248
+ # Get deployment info if available
249
+ deployment = ledger.get_deployment(request.port)
250
+
251
+ if deployment:
252
+ needs_redeploy = ledger.needs_redeploy(
253
+ port=request.port,
254
+ source_hash=request.source_hash,
255
+ build_flags_hash=request.build_flags_hash,
256
+ )
257
+
258
+ # is_current means source and build flags match (no redeploy needed)
259
+ is_current = not needs_redeploy
260
+
261
+ return FirmwareQueryResponse(
262
+ is_current=is_current,
263
+ needs_redeploy=needs_redeploy,
264
+ firmware_hash=deployment.firmware_hash,
265
+ project_dir=deployment.project_dir,
266
+ environment=deployment.environment,
267
+ upload_timestamp=deployment.upload_timestamp,
268
+ message="Firmware info retrieved" if is_current else "Redeploy needed",
269
+ )
270
+ else:
271
+ return FirmwareQueryResponse(
272
+ is_current=False,
273
+ needs_redeploy=True,
274
+ message="No firmware recorded for this port",
275
+ )
276
+
277
+ except KeyboardInterrupt: # noqa: KBI002
278
+ raise
279
+ except Exception as e:
280
+ logging.error(f"Error querying firmware: {e}")
281
+ return FirmwareQueryResponse(
282
+ is_current=False,
283
+ needs_redeploy=True,
284
+ message=f"Error querying firmware: {e}",
285
+ )
286
+
287
+ def handle_firmware_record(
288
+ self,
289
+ request: FirmwareRecordRequest,
290
+ context: "DaemonContext",
291
+ ) -> GenericResponse:
292
+ """Handle a firmware record request.
293
+
294
+ Records a successful deployment in the firmware ledger.
295
+
296
+ Args:
297
+ request: Firmware record request
298
+ context: Daemon context with firmware ledger
299
+
300
+ Returns:
301
+ GenericResponse indicating success or failure
302
+ """
303
+ ledger = context.firmware_ledger
304
+
305
+ logging.info(f"Firmware record request: port={request.port}, " f"project={request.project_dir}, env={request.environment}")
306
+
307
+ try:
308
+ ledger.record_deployment(
309
+ port=request.port,
310
+ firmware_hash=request.firmware_hash,
311
+ source_hash=request.source_hash,
312
+ project_dir=request.project_dir,
313
+ environment=request.environment,
314
+ build_flags_hash=request.build_flags_hash,
315
+ )
316
+
317
+ return GenericResponse(
318
+ success=True,
319
+ message="Firmware deployment recorded",
320
+ data={
321
+ "port": request.port,
322
+ "firmware_hash": request.firmware_hash,
323
+ "source_hash": request.source_hash,
324
+ },
325
+ )
326
+
327
+ except KeyboardInterrupt: # noqa: KBI002
328
+ raise
329
+ except Exception as e:
330
+ logging.error(f"Error recording firmware: {e}")
331
+ return GenericResponse(
332
+ success=False,
333
+ message=f"Error recording firmware: {e}",
334
+ )
335
+
336
+
337
+ class SerialSessionProcessor:
338
+ """Processor for serial session management requests.
339
+
340
+ Handles serial attach, detach, write, and buffer read requests
341
+ using the SharedSerialManager from the daemon context.
342
+ """
343
+
344
+ def handle_serial_attach(
345
+ self,
346
+ request: SerialAttachRequest,
347
+ context: "DaemonContext",
348
+ ) -> SerialSessionResponse:
349
+ """Handle a serial attach request.
350
+
351
+ Attaches a client to a serial session as a reader, or opens
352
+ a new serial port if not already open.
353
+
354
+ Args:
355
+ request: Serial attach request
356
+ context: Daemon context with shared serial manager
357
+
358
+ Returns:
359
+ SerialSessionResponse with session status
360
+ """
361
+ manager = context.shared_serial_manager
362
+
363
+ logging.info(f"Serial attach request: client={request.client_id}, " f"port={request.port}, as_reader={request.as_reader}")
364
+
365
+ try:
366
+ # Check if port is already open
367
+ session_info = manager.get_session_info(request.port) or {}
368
+ if session_info and session_info.get("is_open", False):
369
+ if request.as_reader:
370
+ # Attach as reader to existing session
371
+ success = manager.attach_reader(request.port, request.client_id)
372
+ if success:
373
+ # Track resource attachment
374
+ resource_key = f"serial:{request.port}"
375
+ context.client_manager.attach_resource(request.client_id, resource_key)
376
+
377
+ session_info_new = manager.get_session_info(request.port) or {}
378
+ return SerialSessionResponse(
379
+ success=success,
380
+ message="Attached as reader" if success else "Failed to attach",
381
+ is_open=True,
382
+ reader_count=len(session_info_new.get("reader_client_ids", [])),
383
+ has_writer=session_info_new.get("writer_client_id") is not None,
384
+ buffer_size=session_info_new.get("buffer_size", 0),
385
+ )
386
+ else:
387
+ # Port already open, can't open again
388
+ return SerialSessionResponse(
389
+ success=False,
390
+ message="Port already open by another client",
391
+ is_open=True,
392
+ reader_count=len(session_info.get("reader_client_ids", [])),
393
+ has_writer=session_info.get("writer_client_id") is not None,
394
+ )
395
+ else:
396
+ # Open new port
397
+ success = manager.open_port(
398
+ port=request.port,
399
+ baud_rate=request.baud_rate,
400
+ client_id=request.client_id,
401
+ )
402
+
403
+ if success:
404
+ # Track resource attachment
405
+ resource_key = f"serial:{request.port}"
406
+ context.client_manager.attach_resource(request.client_id, resource_key)
407
+
408
+ new_session_info = manager.get_session_info(request.port) or {}
409
+ return SerialSessionResponse(
410
+ success=True,
411
+ message="Port opened and attached",
412
+ is_open=True,
413
+ reader_count=len(new_session_info.get("reader_client_ids", [])),
414
+ has_writer=new_session_info.get("writer_client_id") is not None,
415
+ buffer_size=0,
416
+ )
417
+ else:
418
+ return SerialSessionResponse(
419
+ success=False,
420
+ message="Failed to open port",
421
+ is_open=False,
422
+ )
423
+
424
+ except KeyboardInterrupt: # noqa: KBI002
425
+ raise
426
+ except Exception as e:
427
+ logging.error(f"Error in serial attach: {e}")
428
+ return SerialSessionResponse(
429
+ success=False,
430
+ message=f"Error: {e}",
431
+ is_open=False,
432
+ )
433
+
434
+ def handle_serial_detach(
435
+ self,
436
+ request: SerialDetachRequest,
437
+ context: "DaemonContext",
438
+ ) -> SerialSessionResponse:
439
+ """Handle a serial detach request.
440
+
441
+ Detaches a client from a serial session. Optionally closes the port
442
+ if this is the last reader.
443
+
444
+ Args:
445
+ request: Serial detach request
446
+ context: Daemon context with shared serial manager
447
+
448
+ Returns:
449
+ SerialSessionResponse with session status
450
+ """
451
+ manager = context.shared_serial_manager
452
+
453
+ logging.info(f"Serial detach request: client={request.client_id}, " f"port={request.port}, close_port={request.close_port}")
454
+
455
+ try:
456
+ # Detach reader
457
+ success = manager.detach_reader(request.port, request.client_id)
458
+
459
+ if success:
460
+ # Remove resource tracking
461
+ resource_key = f"serial:{request.port}"
462
+ context.client_manager.detach_resource(request.client_id, resource_key)
463
+
464
+ # Check if we should close the port
465
+ session_info = manager.get_session_info(request.port) or {}
466
+ port_is_open = session_info.get("is_open", False)
467
+
468
+ if request.close_port and port_is_open:
469
+ if len(session_info.get("reader_client_ids", [])) == 0:
470
+ manager.close_port(request.port, request.client_id)
471
+ return SerialSessionResponse(
472
+ success=True,
473
+ message="Detached and port closed",
474
+ is_open=False,
475
+ reader_count=0,
476
+ )
477
+
478
+ if port_is_open:
479
+ session_info = manager.get_session_info(request.port) or {}
480
+ return SerialSessionResponse(
481
+ success=success,
482
+ message="Detached from session" if success else "Not attached",
483
+ is_open=True,
484
+ reader_count=len(session_info.get("reader_client_ids", [])),
485
+ has_writer=session_info.get("writer_client_id") is not None,
486
+ buffer_size=session_info.get("buffer_size", 0),
487
+ )
488
+ else:
489
+ return SerialSessionResponse(
490
+ success=success,
491
+ message="Detached (port not open)",
492
+ is_open=False,
493
+ )
494
+
495
+ except KeyboardInterrupt: # noqa: KBI002
496
+ raise
497
+ except Exception as e:
498
+ logging.error(f"Error in serial detach: {e}")
499
+ return SerialSessionResponse(
500
+ success=False,
501
+ message=f"Error: {e}",
502
+ )
503
+
504
+ def handle_serial_write(
505
+ self,
506
+ request: SerialWriteRequest,
507
+ context: "DaemonContext",
508
+ ) -> SerialSessionResponse:
509
+ """Handle a serial write request.
510
+
511
+ Writes data to a serial port. The client must have writer access
512
+ or request to acquire it.
513
+
514
+ Args:
515
+ request: Serial write request (data is base64 encoded)
516
+ context: Daemon context with shared serial manager
517
+
518
+ Returns:
519
+ SerialSessionResponse with write status
520
+ """
521
+ manager = context.shared_serial_manager
522
+
523
+ logging.debug(f"Serial write request: client={request.client_id}, port={request.port}")
524
+
525
+ try:
526
+ # Decode base64 data
527
+ data_bytes = base64.b64decode(request.data)
528
+
529
+ # Check if client has writer access
530
+ session_info = manager.get_session_info(request.port) or {}
531
+ current_writer = session_info.get("writer_client_id") if session_info else None
532
+
533
+ if current_writer != request.client_id:
534
+ if request.acquire_writer:
535
+ # Try to acquire writer access
536
+ acquired = manager.acquire_writer(request.port, request.client_id)
537
+ if not acquired:
538
+ return SerialSessionResponse(
539
+ success=False,
540
+ message="Could not acquire writer access",
541
+ is_open=session_info.get("is_open", False),
542
+ has_writer=session_info.get("writer_client_id") is not None,
543
+ )
544
+ else:
545
+ return SerialSessionResponse(
546
+ success=False,
547
+ message="No writer access and acquire_writer is False",
548
+ is_open=session_info.get("is_open", False),
549
+ has_writer=True,
550
+ )
551
+
552
+ # Write data
553
+ bytes_written = manager.write(request.port, request.client_id, data_bytes)
554
+
555
+ return SerialSessionResponse(
556
+ success=bytes_written > 0,
557
+ message=f"Wrote {bytes_written} bytes",
558
+ is_open=True,
559
+ bytes_written=bytes_written,
560
+ )
561
+
562
+ except KeyboardInterrupt: # noqa: KBI002
563
+ raise
564
+ except Exception as e:
565
+ logging.error(f"Error in serial write: {e}")
566
+ return SerialSessionResponse(
567
+ success=False,
568
+ message=f"Error: {e}",
569
+ )
570
+
571
+ def handle_serial_buffer(
572
+ self,
573
+ request: SerialBufferRequest,
574
+ context: "DaemonContext",
575
+ ) -> SerialSessionResponse:
576
+ """Handle a serial buffer read request.
577
+
578
+ Reads buffered output from a serial session.
579
+
580
+ Args:
581
+ request: Serial buffer request
582
+ context: Daemon context with shared serial manager
583
+
584
+ Returns:
585
+ SerialSessionResponse with buffered lines
586
+ """
587
+ manager = context.shared_serial_manager
588
+
589
+ logging.debug(f"Serial buffer request: client={request.client_id}, " f"port={request.port}, max_lines={request.max_lines}")
590
+
591
+ try:
592
+ session_info = manager.get_session_info(request.port) or {}
593
+ if not session_info.get("is_open", False):
594
+ return SerialSessionResponse(
595
+ success=False,
596
+ message="Port not open",
597
+ is_open=False,
598
+ )
599
+
600
+ # Read buffer
601
+ lines = manager.read_buffer(
602
+ port=request.port,
603
+ client_id=request.client_id,
604
+ max_lines=request.max_lines,
605
+ )
606
+
607
+ session_info = manager.get_session_info(request.port) or {}
608
+
609
+ return SerialSessionResponse(
610
+ success=True,
611
+ message=f"Read {len(lines)} lines",
612
+ is_open=True,
613
+ reader_count=len(session_info.get("reader_client_ids", [])),
614
+ has_writer=session_info.get("writer_client_id") is not None,
615
+ buffer_size=session_info.get("buffer_size", 0),
616
+ lines=lines,
617
+ )
618
+
619
+ except KeyboardInterrupt: # noqa: KBI002
620
+ raise
621
+ except Exception as e:
622
+ logging.error(f"Error in serial buffer read: {e}")
623
+ return SerialSessionResponse(
624
+ success=False,
625
+ message=f"Error: {e}",
626
+ )
627
+
628
+
629
+ class ClientConnectionProcessor:
630
+ """Processor for client connection management requests.
631
+
632
+ Handles client connect, heartbeat, and disconnect requests using
633
+ the ClientConnectionManager from the daemon context.
634
+ """
635
+
636
+ def handle_client_connect(
637
+ self,
638
+ request: ClientConnectRequest,
639
+ context: "DaemonContext",
640
+ ) -> ClientResponse:
641
+ """Handle a client connection request.
642
+
643
+ Registers a new client with the daemon.
644
+
645
+ Args:
646
+ request: Client connect request
647
+ context: Daemon context with client manager
648
+
649
+ Returns:
650
+ ClientResponse with registration status
651
+ """
652
+ manager = context.client_manager
653
+
654
+ logging.info(f"Client connect request: client_id={request.client_id}, pid={request.pid}")
655
+
656
+ try:
657
+ # Build metadata from request
658
+ metadata = {
659
+ "hostname": request.hostname,
660
+ "version": request.version,
661
+ "connect_timestamp": request.timestamp,
662
+ }
663
+
664
+ # Register client
665
+ client_info = manager.register_client(
666
+ client_id=request.client_id,
667
+ pid=request.pid,
668
+ metadata=metadata,
669
+ )
670
+
671
+ return ClientResponse(
672
+ success=True,
673
+ message="Client registered",
674
+ client_id=client_info.client_id,
675
+ is_registered=True,
676
+ total_clients=manager.get_client_count(),
677
+ )
678
+
679
+ except KeyboardInterrupt: # noqa: KBI002
680
+ raise
681
+ except Exception as e:
682
+ logging.error(f"Error registering client: {e}")
683
+ return ClientResponse(
684
+ success=False,
685
+ message=f"Error: {e}",
686
+ client_id=request.client_id,
687
+ is_registered=False,
688
+ )
689
+
690
+ def handle_client_heartbeat(
691
+ self,
692
+ request: ClientHeartbeatRequest,
693
+ context: "DaemonContext",
694
+ ) -> ClientResponse:
695
+ """Handle a client heartbeat request.
696
+
697
+ Updates the last heartbeat time for a client.
698
+
699
+ Args:
700
+ request: Client heartbeat request
701
+ context: Daemon context with client manager
702
+
703
+ Returns:
704
+ ClientResponse with heartbeat status
705
+ """
706
+ manager = context.client_manager
707
+
708
+ logging.debug(f"Client heartbeat: client_id={request.client_id}")
709
+
710
+ try:
711
+ success = manager.heartbeat(request.client_id)
712
+
713
+ return ClientResponse(
714
+ success=success,
715
+ message="Heartbeat recorded" if success else "Unknown client",
716
+ client_id=request.client_id,
717
+ is_registered=success,
718
+ total_clients=manager.get_client_count(),
719
+ )
720
+
721
+ except KeyboardInterrupt: # noqa: KBI002
722
+ raise
723
+ except Exception as e:
724
+ logging.error(f"Error processing heartbeat: {e}")
725
+ return ClientResponse(
726
+ success=False,
727
+ message=f"Error: {e}",
728
+ client_id=request.client_id,
729
+ )
730
+
731
+ def handle_client_disconnect(
732
+ self,
733
+ request: ClientDisconnectRequest,
734
+ context: "DaemonContext",
735
+ ) -> ClientResponse:
736
+ """Handle a client disconnect request.
737
+
738
+ Unregisters a client and releases all its resources.
739
+
740
+ Args:
741
+ request: Client disconnect request
742
+ context: Daemon context with client manager
743
+
744
+ Returns:
745
+ ClientResponse with disconnect status
746
+ """
747
+ manager = context.client_manager
748
+
749
+ logging.info(f"Client disconnect request: client_id={request.client_id}, " f"reason={request.reason}")
750
+
751
+ try:
752
+ success = manager.unregister_client(request.client_id)
753
+
754
+ return ClientResponse(
755
+ success=success,
756
+ message="Client disconnected" if success else "Client not found",
757
+ client_id=request.client_id,
758
+ is_registered=False,
759
+ total_clients=manager.get_client_count(),
760
+ )
761
+
762
+ except KeyboardInterrupt: # noqa: KBI002
763
+ raise
764
+ except Exception as e:
765
+ logging.error(f"Error disconnecting client: {e}")
766
+ return ClientResponse(
767
+ success=False,
768
+ message=f"Error: {e}",
769
+ client_id=request.client_id,
770
+ )
771
+
772
+
773
+ # Create singleton instances for easy access
774
+ locking_processor = LockingRequestProcessor()
775
+ firmware_processor = FirmwareRequestProcessor()
776
+ serial_processor = SerialSessionProcessor()
777
+ client_processor = ClientConnectionProcessor()