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.
- fbuild/__init__.py +390 -0
- fbuild/assets/example.txt +1 -0
- fbuild/build/__init__.py +117 -0
- fbuild/build/archive_creator.py +186 -0
- fbuild/build/binary_generator.py +444 -0
- fbuild/build/build_component_factory.py +131 -0
- fbuild/build/build_info_generator.py +624 -0
- fbuild/build/build_state.py +325 -0
- fbuild/build/build_utils.py +93 -0
- fbuild/build/compilation_executor.py +422 -0
- fbuild/build/compiler.py +165 -0
- fbuild/build/compiler_avr.py +574 -0
- fbuild/build/configurable_compiler.py +664 -0
- fbuild/build/configurable_linker.py +637 -0
- fbuild/build/flag_builder.py +214 -0
- fbuild/build/library_dependency_processor.py +185 -0
- fbuild/build/linker.py +708 -0
- fbuild/build/orchestrator.py +67 -0
- fbuild/build/orchestrator_avr.py +651 -0
- fbuild/build/orchestrator_esp32.py +878 -0
- fbuild/build/orchestrator_rp2040.py +719 -0
- fbuild/build/orchestrator_stm32.py +696 -0
- fbuild/build/orchestrator_teensy.py +580 -0
- fbuild/build/source_compilation_orchestrator.py +218 -0
- fbuild/build/source_scanner.py +516 -0
- fbuild/cli.py +717 -0
- fbuild/cli_utils.py +314 -0
- fbuild/config/__init__.py +16 -0
- fbuild/config/board_config.py +542 -0
- fbuild/config/board_loader.py +92 -0
- fbuild/config/ini_parser.py +369 -0
- fbuild/config/mcu_specs.py +88 -0
- fbuild/daemon/__init__.py +42 -0
- fbuild/daemon/async_client.py +531 -0
- fbuild/daemon/client.py +1505 -0
- fbuild/daemon/compilation_queue.py +293 -0
- fbuild/daemon/configuration_lock.py +865 -0
- fbuild/daemon/daemon.py +585 -0
- fbuild/daemon/daemon_context.py +293 -0
- fbuild/daemon/error_collector.py +263 -0
- fbuild/daemon/file_cache.py +332 -0
- fbuild/daemon/firmware_ledger.py +546 -0
- fbuild/daemon/lock_manager.py +508 -0
- fbuild/daemon/logging_utils.py +149 -0
- fbuild/daemon/messages.py +957 -0
- fbuild/daemon/operation_registry.py +288 -0
- fbuild/daemon/port_state_manager.py +249 -0
- fbuild/daemon/process_tracker.py +366 -0
- fbuild/daemon/processors/__init__.py +18 -0
- fbuild/daemon/processors/build_processor.py +248 -0
- fbuild/daemon/processors/deploy_processor.py +664 -0
- fbuild/daemon/processors/install_deps_processor.py +431 -0
- fbuild/daemon/processors/locking_processor.py +777 -0
- fbuild/daemon/processors/monitor_processor.py +285 -0
- fbuild/daemon/request_processor.py +457 -0
- fbuild/daemon/shared_serial.py +819 -0
- fbuild/daemon/status_manager.py +238 -0
- fbuild/daemon/subprocess_manager.py +316 -0
- fbuild/deploy/__init__.py +21 -0
- fbuild/deploy/deployer.py +67 -0
- fbuild/deploy/deployer_esp32.py +310 -0
- fbuild/deploy/docker_utils.py +315 -0
- fbuild/deploy/monitor.py +519 -0
- fbuild/deploy/qemu_runner.py +603 -0
- fbuild/interrupt_utils.py +34 -0
- fbuild/ledger/__init__.py +52 -0
- fbuild/ledger/board_ledger.py +560 -0
- fbuild/output.py +352 -0
- fbuild/packages/__init__.py +66 -0
- fbuild/packages/archive_utils.py +1098 -0
- fbuild/packages/arduino_core.py +412 -0
- fbuild/packages/cache.py +256 -0
- fbuild/packages/concurrent_manager.py +510 -0
- fbuild/packages/downloader.py +518 -0
- fbuild/packages/fingerprint.py +423 -0
- fbuild/packages/framework_esp32.py +538 -0
- fbuild/packages/framework_rp2040.py +349 -0
- fbuild/packages/framework_stm32.py +459 -0
- fbuild/packages/framework_teensy.py +346 -0
- fbuild/packages/github_utils.py +96 -0
- fbuild/packages/header_trampoline_cache.py +394 -0
- fbuild/packages/library_compiler.py +203 -0
- fbuild/packages/library_manager.py +549 -0
- fbuild/packages/library_manager_esp32.py +725 -0
- fbuild/packages/package.py +163 -0
- fbuild/packages/platform_esp32.py +383 -0
- fbuild/packages/platform_rp2040.py +400 -0
- fbuild/packages/platform_stm32.py +581 -0
- fbuild/packages/platform_teensy.py +312 -0
- fbuild/packages/platform_utils.py +131 -0
- fbuild/packages/platformio_registry.py +369 -0
- fbuild/packages/sdk_utils.py +231 -0
- fbuild/packages/toolchain.py +436 -0
- fbuild/packages/toolchain_binaries.py +196 -0
- fbuild/packages/toolchain_esp32.py +489 -0
- fbuild/packages/toolchain_metadata.py +185 -0
- fbuild/packages/toolchain_rp2040.py +436 -0
- fbuild/packages/toolchain_stm32.py +417 -0
- fbuild/packages/toolchain_teensy.py +404 -0
- fbuild/platform_configs/esp32.json +150 -0
- fbuild/platform_configs/esp32c2.json +144 -0
- fbuild/platform_configs/esp32c3.json +143 -0
- fbuild/platform_configs/esp32c5.json +151 -0
- fbuild/platform_configs/esp32c6.json +151 -0
- fbuild/platform_configs/esp32p4.json +149 -0
- fbuild/platform_configs/esp32s3.json +151 -0
- fbuild/platform_configs/imxrt1062.json +56 -0
- fbuild/platform_configs/rp2040.json +70 -0
- fbuild/platform_configs/rp2350.json +76 -0
- fbuild/platform_configs/stm32f1.json +59 -0
- fbuild/platform_configs/stm32f4.json +63 -0
- fbuild/py.typed +0 -0
- fbuild-1.2.8.dist-info/METADATA +468 -0
- fbuild-1.2.8.dist-info/RECORD +121 -0
- fbuild-1.2.8.dist-info/WHEEL +5 -0
- fbuild-1.2.8.dist-info/entry_points.txt +5 -0
- fbuild-1.2.8.dist-info/licenses/LICENSE +21 -0
- fbuild-1.2.8.dist-info/top_level.txt +2 -0
- fbuild_lint/__init__.py +0 -0
- fbuild_lint/ruff_plugins/__init__.py +0 -0
- fbuild_lint/ruff_plugins/keyboard_interrupt_checker.py +158 -0
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Firmware Ledger - Track deployed firmware on devices.
|
|
3
|
+
|
|
4
|
+
This module provides a ledger to track what firmware is currently deployed on each
|
|
5
|
+
device/port, allowing clients to skip re-upload if the same firmware is already running.
|
|
6
|
+
The cache is stored in ~/.fbuild/firmware_ledger.json (or dev path if FBUILD_DEV_MODE)
|
|
7
|
+
and uses file locking for thread-safe access.
|
|
8
|
+
|
|
9
|
+
Features:
|
|
10
|
+
- Port to firmware hash mapping with timestamps
|
|
11
|
+
- Source file hash tracking for change detection
|
|
12
|
+
- Build flags hash for build configuration tracking
|
|
13
|
+
- Automatic stale entry expiration (configurable, default 24 hours)
|
|
14
|
+
- Thread-safe file access with file locking
|
|
15
|
+
- Skip re-upload when firmware matches what's deployed
|
|
16
|
+
|
|
17
|
+
Example:
|
|
18
|
+
>>> from fbuild.daemon.firmware_ledger import FirmwareLedger, compute_firmware_hash
|
|
19
|
+
>>>
|
|
20
|
+
>>> # Record a deployment
|
|
21
|
+
>>> ledger = FirmwareLedger()
|
|
22
|
+
>>> fw_hash = compute_firmware_hash(Path("firmware.bin"))
|
|
23
|
+
>>> ledger.record_deployment("COM3", fw_hash, "abc123", "/path/to/project", "esp32dev")
|
|
24
|
+
>>>
|
|
25
|
+
>>> # Check if firmware is current
|
|
26
|
+
>>> if ledger.is_current("COM3", fw_hash, "abc123"):
|
|
27
|
+
>>> print("Firmware already deployed, skipping upload")
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import hashlib
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import sys
|
|
34
|
+
import threading
|
|
35
|
+
import time
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Any
|
|
39
|
+
|
|
40
|
+
# Stale entry threshold: 24 hours (in seconds)
|
|
41
|
+
DEFAULT_STALE_THRESHOLD_SECONDS = 24 * 60 * 60
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _get_ledger_path() -> Path:
|
|
45
|
+
"""Get the path to the firmware ledger file.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Path to firmware_ledger.json, respecting FBUILD_DEV_MODE
|
|
49
|
+
"""
|
|
50
|
+
if os.environ.get("FBUILD_DEV_MODE") == "1":
|
|
51
|
+
# Use project-local directory for development
|
|
52
|
+
return Path.cwd() / ".fbuild" / "daemon_dev" / "firmware_ledger.json"
|
|
53
|
+
else:
|
|
54
|
+
# Use home directory for production
|
|
55
|
+
return Path.home() / ".fbuild" / "firmware_ledger.json"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class FirmwareLedgerError(Exception):
|
|
59
|
+
"""Raised when firmware ledger operations fail."""
|
|
60
|
+
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class FirmwareEntry:
|
|
66
|
+
"""A single entry in the firmware ledger.
|
|
67
|
+
|
|
68
|
+
Attributes:
|
|
69
|
+
port: Serial port name (e.g., "COM3", "/dev/ttyUSB0")
|
|
70
|
+
firmware_hash: SHA256 hash of the firmware file (.bin/.hex)
|
|
71
|
+
source_hash: Combined hash of all source files
|
|
72
|
+
project_dir: Absolute path to the project directory
|
|
73
|
+
environment: Build environment name (e.g., "esp32dev", "uno")
|
|
74
|
+
upload_timestamp: Unix timestamp when firmware was uploaded
|
|
75
|
+
build_flags_hash: Optional hash of build flags (for detecting config changes)
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
port: str
|
|
79
|
+
firmware_hash: str
|
|
80
|
+
source_hash: str
|
|
81
|
+
project_dir: str
|
|
82
|
+
environment: str
|
|
83
|
+
upload_timestamp: float
|
|
84
|
+
build_flags_hash: str | None = None
|
|
85
|
+
|
|
86
|
+
def is_stale(self, threshold: float = DEFAULT_STALE_THRESHOLD_SECONDS) -> bool:
|
|
87
|
+
"""Check if this entry is stale (older than threshold).
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
threshold: Maximum age in seconds before entry is considered stale
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
True if entry is older than threshold
|
|
94
|
+
"""
|
|
95
|
+
return (time.time() - self.upload_timestamp) > threshold
|
|
96
|
+
|
|
97
|
+
def to_dict(self) -> dict[str, Any]:
|
|
98
|
+
"""Convert to dictionary for JSON serialization."""
|
|
99
|
+
return {
|
|
100
|
+
"port": self.port,
|
|
101
|
+
"firmware_hash": self.firmware_hash,
|
|
102
|
+
"source_hash": self.source_hash,
|
|
103
|
+
"project_dir": self.project_dir,
|
|
104
|
+
"environment": self.environment,
|
|
105
|
+
"upload_timestamp": self.upload_timestamp,
|
|
106
|
+
"build_flags_hash": self.build_flags_hash,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
def from_dict(cls, data: dict[str, Any]) -> "FirmwareEntry":
|
|
111
|
+
"""Create entry from dictionary.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
data: Dictionary with entry fields
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
FirmwareEntry instance
|
|
118
|
+
"""
|
|
119
|
+
return cls(
|
|
120
|
+
port=data["port"],
|
|
121
|
+
firmware_hash=data["firmware_hash"],
|
|
122
|
+
source_hash=data["source_hash"],
|
|
123
|
+
project_dir=data["project_dir"],
|
|
124
|
+
environment=data["environment"],
|
|
125
|
+
upload_timestamp=data["upload_timestamp"],
|
|
126
|
+
build_flags_hash=data.get("build_flags_hash"),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class FirmwareLedger:
|
|
131
|
+
"""Manages port to firmware mapping with persistent storage.
|
|
132
|
+
|
|
133
|
+
The ledger stores mappings in ~/.fbuild/firmware_ledger.json (or dev path)
|
|
134
|
+
and provides thread-safe access through file locking.
|
|
135
|
+
|
|
136
|
+
Example:
|
|
137
|
+
>>> ledger = FirmwareLedger()
|
|
138
|
+
>>> ledger.record_deployment("COM3", "abc123", "def456", "/path/project", "esp32dev")
|
|
139
|
+
>>> entry = ledger.get_deployment("COM3")
|
|
140
|
+
>>> print(entry.firmware_hash if entry else "Not found")
|
|
141
|
+
>>> ledger.clear("COM3")
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
def __init__(self, ledger_path: Path | None = None):
|
|
145
|
+
"""Initialize the firmware ledger.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
ledger_path: Optional custom path for ledger file.
|
|
149
|
+
Defaults to ~/.fbuild/firmware_ledger.json (or dev path)
|
|
150
|
+
"""
|
|
151
|
+
if ledger_path is None:
|
|
152
|
+
self._ledger_path = _get_ledger_path()
|
|
153
|
+
else:
|
|
154
|
+
self._ledger_path = ledger_path
|
|
155
|
+
|
|
156
|
+
# Thread lock for in-process synchronization
|
|
157
|
+
self._lock = threading.Lock()
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def ledger_path(self) -> Path:
|
|
161
|
+
"""Get the path to the ledger file."""
|
|
162
|
+
return self._ledger_path
|
|
163
|
+
|
|
164
|
+
def _ensure_directory(self) -> None:
|
|
165
|
+
"""Ensure the parent directory exists."""
|
|
166
|
+
self._ledger_path.parent.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
|
|
168
|
+
def _read_ledger(self) -> dict[str, dict[str, Any]]:
|
|
169
|
+
"""Read the ledger file.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Dictionary mapping port names to entry dictionaries
|
|
173
|
+
"""
|
|
174
|
+
if not self._ledger_path.exists():
|
|
175
|
+
return {}
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
with open(self._ledger_path, encoding="utf-8") as f:
|
|
179
|
+
data = json.load(f)
|
|
180
|
+
if not isinstance(data, dict):
|
|
181
|
+
return {}
|
|
182
|
+
return data
|
|
183
|
+
except (json.JSONDecodeError, OSError):
|
|
184
|
+
return {}
|
|
185
|
+
|
|
186
|
+
def _write_ledger(self, data: dict[str, dict[str, Any]]) -> None:
|
|
187
|
+
"""Write the ledger file.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
data: Dictionary mapping port names to entry dictionaries
|
|
191
|
+
"""
|
|
192
|
+
self._ensure_directory()
|
|
193
|
+
try:
|
|
194
|
+
with open(self._ledger_path, "w", encoding="utf-8") as f:
|
|
195
|
+
json.dump(data, f, indent=2)
|
|
196
|
+
except OSError as e:
|
|
197
|
+
raise FirmwareLedgerError(f"Failed to write ledger: {e}") from e
|
|
198
|
+
|
|
199
|
+
def _acquire_file_lock(self) -> Any:
|
|
200
|
+
"""Acquire a file lock for cross-process synchronization.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
Lock file handle (or None on platforms without locking support)
|
|
204
|
+
"""
|
|
205
|
+
self._ensure_directory()
|
|
206
|
+
lock_path = self._ledger_path.with_suffix(".lock")
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
# Open lock file
|
|
210
|
+
lock_file = open(lock_path, "w", encoding="utf-8")
|
|
211
|
+
|
|
212
|
+
# Platform-specific locking
|
|
213
|
+
if sys.platform == "win32":
|
|
214
|
+
import msvcrt
|
|
215
|
+
|
|
216
|
+
msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1)
|
|
217
|
+
else: # pragma: no cover - Unix only
|
|
218
|
+
import fcntl # type: ignore[import-not-found]
|
|
219
|
+
|
|
220
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
|
221
|
+
|
|
222
|
+
return lock_file
|
|
223
|
+
except (ImportError, OSError):
|
|
224
|
+
# Locking not available or failed - continue without lock
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
def _release_file_lock(self, lock_file: Any) -> None:
|
|
228
|
+
"""Release a file lock.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
lock_file: Lock file handle from _acquire_file_lock
|
|
232
|
+
"""
|
|
233
|
+
if lock_file is None:
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
if sys.platform == "win32":
|
|
238
|
+
import msvcrt
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
|
|
242
|
+
except OSError:
|
|
243
|
+
pass
|
|
244
|
+
else: # pragma: no cover - Unix only
|
|
245
|
+
import fcntl # type: ignore[import-not-found]
|
|
246
|
+
|
|
247
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
|
248
|
+
|
|
249
|
+
lock_file.close()
|
|
250
|
+
except (ImportError, OSError):
|
|
251
|
+
pass
|
|
252
|
+
|
|
253
|
+
def record_deployment(
|
|
254
|
+
self,
|
|
255
|
+
port: str,
|
|
256
|
+
firmware_hash: str,
|
|
257
|
+
source_hash: str,
|
|
258
|
+
project_dir: str,
|
|
259
|
+
environment: str,
|
|
260
|
+
build_flags_hash: str | None = None,
|
|
261
|
+
) -> None:
|
|
262
|
+
"""Record that firmware was deployed to a port.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
port: Serial port name (e.g., "COM3", "/dev/ttyUSB0")
|
|
266
|
+
firmware_hash: SHA256 hash of the firmware file
|
|
267
|
+
source_hash: Combined hash of all source files
|
|
268
|
+
project_dir: Absolute path to the project directory
|
|
269
|
+
environment: Build environment name (e.g., "esp32dev")
|
|
270
|
+
build_flags_hash: Optional hash of build flags
|
|
271
|
+
"""
|
|
272
|
+
entry = FirmwareEntry(
|
|
273
|
+
port=port,
|
|
274
|
+
firmware_hash=firmware_hash,
|
|
275
|
+
source_hash=source_hash,
|
|
276
|
+
project_dir=str(project_dir),
|
|
277
|
+
environment=environment,
|
|
278
|
+
upload_timestamp=time.time(),
|
|
279
|
+
build_flags_hash=build_flags_hash,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
with self._lock:
|
|
283
|
+
lock_file = self._acquire_file_lock()
|
|
284
|
+
try:
|
|
285
|
+
data = self._read_ledger()
|
|
286
|
+
data[port] = entry.to_dict()
|
|
287
|
+
self._write_ledger(data)
|
|
288
|
+
finally:
|
|
289
|
+
self._release_file_lock(lock_file)
|
|
290
|
+
|
|
291
|
+
def get_deployment(self, port: str) -> FirmwareEntry | None:
|
|
292
|
+
"""Get the deployment entry for a port.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
port: Serial port name (e.g., "COM3", "/dev/ttyUSB0")
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
FirmwareEntry or None if not found or stale
|
|
299
|
+
"""
|
|
300
|
+
with self._lock:
|
|
301
|
+
lock_file = self._acquire_file_lock()
|
|
302
|
+
try:
|
|
303
|
+
data = self._read_ledger()
|
|
304
|
+
entry_data = data.get(port)
|
|
305
|
+
if entry_data is None:
|
|
306
|
+
return None
|
|
307
|
+
|
|
308
|
+
entry = FirmwareEntry.from_dict(entry_data)
|
|
309
|
+
if entry.is_stale():
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
return entry
|
|
313
|
+
finally:
|
|
314
|
+
self._release_file_lock(lock_file)
|
|
315
|
+
|
|
316
|
+
def is_current(
|
|
317
|
+
self,
|
|
318
|
+
port: str,
|
|
319
|
+
firmware_hash: str,
|
|
320
|
+
source_hash: str,
|
|
321
|
+
) -> bool:
|
|
322
|
+
"""Check if firmware matches what's currently deployed.
|
|
323
|
+
|
|
324
|
+
This is used to determine if we can skip re-uploading firmware.
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
port: Serial port name
|
|
328
|
+
firmware_hash: SHA256 hash of the firmware file
|
|
329
|
+
source_hash: Combined hash of source files
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
True if the firmware and source hashes match the deployed version
|
|
333
|
+
"""
|
|
334
|
+
entry = self.get_deployment(port)
|
|
335
|
+
if entry is None:
|
|
336
|
+
return False
|
|
337
|
+
|
|
338
|
+
return entry.firmware_hash == firmware_hash and entry.source_hash == source_hash
|
|
339
|
+
|
|
340
|
+
def needs_redeploy(
|
|
341
|
+
self,
|
|
342
|
+
port: str,
|
|
343
|
+
source_hash: str,
|
|
344
|
+
build_flags_hash: str | None = None,
|
|
345
|
+
) -> bool:
|
|
346
|
+
"""Check if source has changed and needs redeployment.
|
|
347
|
+
|
|
348
|
+
This checks if the source files or build configuration have changed
|
|
349
|
+
since the last deployment.
|
|
350
|
+
|
|
351
|
+
Args:
|
|
352
|
+
port: Serial port name
|
|
353
|
+
source_hash: Current combined hash of source files
|
|
354
|
+
build_flags_hash: Current hash of build flags (optional)
|
|
355
|
+
|
|
356
|
+
Returns:
|
|
357
|
+
True if source or build flags have changed (needs redeploy),
|
|
358
|
+
False if same source and flags (can skip build/deploy)
|
|
359
|
+
"""
|
|
360
|
+
entry = self.get_deployment(port)
|
|
361
|
+
if entry is None:
|
|
362
|
+
# No previous deployment, needs deploy
|
|
363
|
+
return True
|
|
364
|
+
|
|
365
|
+
# Check source hash
|
|
366
|
+
if entry.source_hash != source_hash:
|
|
367
|
+
return True
|
|
368
|
+
|
|
369
|
+
# Check build flags if provided
|
|
370
|
+
if build_flags_hash is not None and entry.build_flags_hash != build_flags_hash:
|
|
371
|
+
return True
|
|
372
|
+
|
|
373
|
+
return False
|
|
374
|
+
|
|
375
|
+
def clear(self, port: str) -> bool:
|
|
376
|
+
"""Clear the entry for a port.
|
|
377
|
+
|
|
378
|
+
Use this when a device is reset or when you want to force a re-upload.
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
port: Serial port name to clear
|
|
382
|
+
|
|
383
|
+
Returns:
|
|
384
|
+
True if entry was cleared, False if not found
|
|
385
|
+
"""
|
|
386
|
+
with self._lock:
|
|
387
|
+
lock_file = self._acquire_file_lock()
|
|
388
|
+
try:
|
|
389
|
+
data = self._read_ledger()
|
|
390
|
+
if port in data:
|
|
391
|
+
del data[port]
|
|
392
|
+
self._write_ledger(data)
|
|
393
|
+
return True
|
|
394
|
+
return False
|
|
395
|
+
finally:
|
|
396
|
+
self._release_file_lock(lock_file)
|
|
397
|
+
|
|
398
|
+
def clear_all(self) -> int:
|
|
399
|
+
"""Clear all entries from the ledger.
|
|
400
|
+
|
|
401
|
+
Returns:
|
|
402
|
+
Number of entries cleared
|
|
403
|
+
"""
|
|
404
|
+
with self._lock:
|
|
405
|
+
lock_file = self._acquire_file_lock()
|
|
406
|
+
try:
|
|
407
|
+
data = self._read_ledger()
|
|
408
|
+
count = len(data)
|
|
409
|
+
self._write_ledger({})
|
|
410
|
+
return count
|
|
411
|
+
finally:
|
|
412
|
+
self._release_file_lock(lock_file)
|
|
413
|
+
|
|
414
|
+
def clear_stale(
|
|
415
|
+
self,
|
|
416
|
+
threshold_seconds: float = DEFAULT_STALE_THRESHOLD_SECONDS,
|
|
417
|
+
) -> int:
|
|
418
|
+
"""Remove all stale entries from the ledger.
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
threshold_seconds: Maximum age in seconds before entry is considered stale
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
Number of entries removed
|
|
425
|
+
"""
|
|
426
|
+
with self._lock:
|
|
427
|
+
lock_file = self._acquire_file_lock()
|
|
428
|
+
try:
|
|
429
|
+
data = self._read_ledger()
|
|
430
|
+
original_count = len(data)
|
|
431
|
+
|
|
432
|
+
# Filter out stale entries
|
|
433
|
+
fresh_data = {}
|
|
434
|
+
for port, entry_data in data.items():
|
|
435
|
+
entry = FirmwareEntry.from_dict(entry_data)
|
|
436
|
+
if not entry.is_stale(threshold_seconds):
|
|
437
|
+
fresh_data[port] = entry_data
|
|
438
|
+
|
|
439
|
+
self._write_ledger(fresh_data)
|
|
440
|
+
return original_count - len(fresh_data)
|
|
441
|
+
finally:
|
|
442
|
+
self._release_file_lock(lock_file)
|
|
443
|
+
|
|
444
|
+
def get_all(self) -> dict[str, FirmwareEntry]:
|
|
445
|
+
"""Get all non-stale entries in the ledger.
|
|
446
|
+
|
|
447
|
+
Returns:
|
|
448
|
+
Dictionary mapping port names to FirmwareEntry objects
|
|
449
|
+
"""
|
|
450
|
+
with self._lock:
|
|
451
|
+
lock_file = self._acquire_file_lock()
|
|
452
|
+
try:
|
|
453
|
+
data = self._read_ledger()
|
|
454
|
+
result = {}
|
|
455
|
+
for port, entry_data in data.items():
|
|
456
|
+
entry = FirmwareEntry.from_dict(entry_data)
|
|
457
|
+
if not entry.is_stale():
|
|
458
|
+
result[port] = entry
|
|
459
|
+
return result
|
|
460
|
+
finally:
|
|
461
|
+
self._release_file_lock(lock_file)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def compute_firmware_hash(firmware_path: Path) -> str:
|
|
465
|
+
"""Compute SHA256 hash of a firmware file.
|
|
466
|
+
|
|
467
|
+
Args:
|
|
468
|
+
firmware_path: Path to firmware file (.bin, .hex, etc.)
|
|
469
|
+
|
|
470
|
+
Returns:
|
|
471
|
+
Hexadecimal SHA256 hash string
|
|
472
|
+
|
|
473
|
+
Raises:
|
|
474
|
+
FirmwareLedgerError: If file cannot be read
|
|
475
|
+
"""
|
|
476
|
+
try:
|
|
477
|
+
hasher = hashlib.sha256()
|
|
478
|
+
with open(firmware_path, "rb") as f:
|
|
479
|
+
# Read in chunks for large files
|
|
480
|
+
for chunk in iter(lambda: f.read(65536), b""):
|
|
481
|
+
hasher.update(chunk)
|
|
482
|
+
return hasher.hexdigest()
|
|
483
|
+
except OSError as e:
|
|
484
|
+
raise FirmwareLedgerError(f"Failed to hash firmware file: {e}") from e
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def compute_source_hash(source_files: list[Path]) -> str:
|
|
488
|
+
"""Compute combined hash of multiple source files.
|
|
489
|
+
|
|
490
|
+
The hash is computed by hashing each file's content in sorted order
|
|
491
|
+
(by path) to ensure deterministic results.
|
|
492
|
+
|
|
493
|
+
Args:
|
|
494
|
+
source_files: List of source file paths
|
|
495
|
+
|
|
496
|
+
Returns:
|
|
497
|
+
Hexadecimal SHA256 hash string representing all source files
|
|
498
|
+
|
|
499
|
+
Raises:
|
|
500
|
+
FirmwareLedgerError: If any file cannot be read
|
|
501
|
+
"""
|
|
502
|
+
hasher = hashlib.sha256()
|
|
503
|
+
|
|
504
|
+
# Sort files by path for deterministic ordering
|
|
505
|
+
sorted_files = sorted(source_files, key=lambda p: str(p))
|
|
506
|
+
|
|
507
|
+
for file_path in sorted_files:
|
|
508
|
+
try:
|
|
509
|
+
# Include the relative path in the hash for detecting file renames/moves
|
|
510
|
+
hasher.update(str(file_path).encode("utf-8"))
|
|
511
|
+
hasher.update(b"\x00") # Null separator
|
|
512
|
+
|
|
513
|
+
with open(file_path, "rb") as f:
|
|
514
|
+
for chunk in iter(lambda: f.read(65536), b""):
|
|
515
|
+
hasher.update(chunk)
|
|
516
|
+
hasher.update(b"\x00") # Separator between files
|
|
517
|
+
except OSError as e:
|
|
518
|
+
raise FirmwareLedgerError(f"Failed to hash source file {file_path}: {e}") from e
|
|
519
|
+
|
|
520
|
+
return hasher.hexdigest()
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def compute_build_flags_hash(build_flags: list[str] | str | None) -> str:
|
|
524
|
+
"""Compute hash of build flags.
|
|
525
|
+
|
|
526
|
+
Args:
|
|
527
|
+
build_flags: Build flags as a list of strings or a single string
|
|
528
|
+
|
|
529
|
+
Returns:
|
|
530
|
+
Hexadecimal SHA256 hash string
|
|
531
|
+
"""
|
|
532
|
+
hasher = hashlib.sha256()
|
|
533
|
+
|
|
534
|
+
if build_flags is None:
|
|
535
|
+
return hasher.hexdigest()
|
|
536
|
+
|
|
537
|
+
if isinstance(build_flags, str):
|
|
538
|
+
build_flags = [build_flags]
|
|
539
|
+
|
|
540
|
+
# Sort flags for deterministic ordering
|
|
541
|
+
sorted_flags = sorted(build_flags)
|
|
542
|
+
for flag in sorted_flags:
|
|
543
|
+
hasher.update(flag.encode("utf-8"))
|
|
544
|
+
hasher.update(b"\x00")
|
|
545
|
+
|
|
546
|
+
return hasher.hexdigest()
|