pyflashgui 0.1.0__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.
- pyflashgui/__init__.py +6 -0
- pyflashgui/__main__.py +334 -0
- pyflashgui/backends/__init__.py +40 -0
- pyflashgui/backends/base.py +272 -0
- pyflashgui/backends/jlink_backend.py +596 -0
- pyflashgui/backends/pyocd_backend.py +271 -0
- pyflashgui/backends/simulated.py +240 -0
- pyflashgui/core/__init__.py +2 -0
- pyflashgui/core/checkout.py +134 -0
- pyflashgui/core/compare.py +157 -0
- pyflashgui/core/context.py +37 -0
- pyflashgui/core/events.py +71 -0
- pyflashgui/core/excepthook.py +110 -0
- pyflashgui/core/flasher.py +419 -0
- pyflashgui/core/images.py +279 -0
- pyflashgui/core/installation.py +137 -0
- pyflashgui/core/layout.py +72 -0
- pyflashgui/core/memory_map.py +234 -0
- pyflashgui/core/packages.py +104 -0
- pyflashgui/core/paths.py +49 -0
- pyflashgui/core/settings.py +44 -0
- pyflashgui/core/shortcut.py +154 -0
- pyflashgui/core/slow_start.py +101 -0
- pyflashgui/core/timing.py +312 -0
- pyflashgui/core/updates.py +195 -0
- pyflashgui/core/worker.py +71 -0
- pyflashgui/core/workspaces.py +250 -0
- pyflashgui/help/__init__.py +129 -0
- pyflashgui/help/about.md +21 -0
- pyflashgui/help/changelog.md +46 -0
- pyflashgui/help/files.md +60 -0
- pyflashgui/help/main-window.png +0 -0
- pyflashgui/help/manual.md +44 -0
- pyflashgui/help/memory-map.md +28 -0
- pyflashgui/help/memory.md +59 -0
- pyflashgui/help/probe.md +71 -0
- pyflashgui/resources/__init__.py +10 -0
- pyflashgui/resources/pyflashgui.ico +0 -0
- pyflashgui/resources/pyflashgui.png +0 -0
- pyflashgui/resources/pyflashgui.svg +32 -0
- pyflashgui/resources/pyflashgui_small.svg +20 -0
- pyflashgui/ui/__init__.py +2 -0
- pyflashgui/ui/confirm.py +312 -0
- pyflashgui/ui/detached.py +101 -0
- pyflashgui/ui/erase_dialog.py +141 -0
- pyflashgui/ui/event_colours.py +66 -0
- pyflashgui/ui/files_view.py +716 -0
- pyflashgui/ui/folders.py +176 -0
- pyflashgui/ui/fonts.py +75 -0
- pyflashgui/ui/help_menu.py +587 -0
- pyflashgui/ui/main_window.py +754 -0
- pyflashgui/ui/map_view.py +215 -0
- pyflashgui/ui/memory_view.py +724 -0
- pyflashgui/ui/messages.py +78 -0
- pyflashgui/ui/pane_bar.py +92 -0
- pyflashgui/ui/panes.py +1068 -0
- pyflashgui/ui/persist.py +132 -0
- pyflashgui/ui/probe_bar.py +315 -0
- pyflashgui/ui/read_dialog.py +161 -0
- pyflashgui/ui/session.py +53 -0
- pyflashgui/ui/theme.py +85 -0
- pyflashgui-0.1.0.dist-info/METADATA +80 -0
- pyflashgui-0.1.0.dist-info/RECORD +68 -0
- pyflashgui-0.1.0.dist-info/WHEEL +5 -0
- pyflashgui-0.1.0.dist-info/entry_points.txt +2 -0
- pyflashgui-0.1.0.dist-info/licenses/LICENSE +202 -0
- pyflashgui-0.1.0.dist-info/licenses/NOTICE +30 -0
- pyflashgui-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 davhodg
|
|
3
|
+
"""The one thing in the window that talks to a backend.
|
|
4
|
+
|
|
5
|
+
Every call to a backend blocks, and programming blocks for as long as it
|
|
6
|
+
takes, so none of them is made from the GUI thread. They are queued on one
|
|
7
|
+
worker and run one at a time, which is also what a probe wants: it is one
|
|
8
|
+
piece of hardware and does one thing at a time.
|
|
9
|
+
|
|
10
|
+
What comes back arrives as signals, on the GUI thread, and as lines in the
|
|
11
|
+
Event Log. A pane asks for something to be done and listens; it never holds a
|
|
12
|
+
connection of its own, so two panes cannot be talking to the same probe.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Callable, Sequence
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from PySide6.QtCore import QObject, Signal
|
|
23
|
+
|
|
24
|
+
from pyflashgui import backends
|
|
25
|
+
from pyflashgui.backends.base import (
|
|
26
|
+
ERASE_SECTORS,
|
|
27
|
+
Backend,
|
|
28
|
+
BackendError,
|
|
29
|
+
CancelledError,
|
|
30
|
+
Connection,
|
|
31
|
+
Verification,
|
|
32
|
+
)
|
|
33
|
+
from pyflashgui.core.events import EventLog
|
|
34
|
+
from pyflashgui.core.images import Image, Segment
|
|
35
|
+
from pyflashgui.core.memory_map import Region
|
|
36
|
+
from pyflashgui.core.worker import Worker
|
|
37
|
+
|
|
38
|
+
#: Progress is passed on when it has moved this far, and not otherwise: a
|
|
39
|
+
#: library reporting every page would otherwise queue a thousand signals to
|
|
40
|
+
#: move a bar a thousand times by less than a pixel.
|
|
41
|
+
PROGRESS_STEP = 0.01
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def size_text(count: int) -> str:
|
|
45
|
+
"""A number of bytes as somebody would say it: 512 bytes, 12.5 KiB, 2.0 MiB."""
|
|
46
|
+
if count < 1024:
|
|
47
|
+
return "1 byte" if count == 1 else f"{count} bytes"
|
|
48
|
+
if count < 1024 * 1024:
|
|
49
|
+
return f"{count / 1024:.1f} KiB"
|
|
50
|
+
return f"{count / (1024 * 1024):.1f} MiB"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def verification_text(result: Verification) -> str:
|
|
54
|
+
"""What verifying found, in a sentence."""
|
|
55
|
+
if result.ok:
|
|
56
|
+
return f"Verified: {size_text(result.checked)} match the files."
|
|
57
|
+
address, wanted, found = result.first
|
|
58
|
+
count = "1 byte differs" if result.different == 1 else f"{result.different} bytes differ"
|
|
59
|
+
return (
|
|
60
|
+
f"Verify failed: {count} of {result.checked}. The first is at {address:08X}, "
|
|
61
|
+
f"where the file has {wanted:02X} and the target has {found:02X}."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Flasher(QObject):
|
|
66
|
+
#: The probes plugged in, as a list of ProbeInfo, after ``refresh_probes``.
|
|
67
|
+
probes_changed = Signal(list)
|
|
68
|
+
#: The targets the backend has, as a list of TargetInfo.
|
|
69
|
+
targets_changed = Signal(list)
|
|
70
|
+
#: Connected, or not any more.
|
|
71
|
+
connection_changed = Signal(bool)
|
|
72
|
+
#: Something is being done, or has finished. Nothing else can be asked
|
|
73
|
+
#: for in between.
|
|
74
|
+
busy_changed = Signal(bool)
|
|
75
|
+
#: What is being done, and how far along it is from 0.0 to 1.0.
|
|
76
|
+
progress = Signal(str, float)
|
|
77
|
+
#: The memory of the target in question is known, or has changed.
|
|
78
|
+
regions_changed = Signal()
|
|
79
|
+
#: An operation ended: its name, and whether it did what was asked.
|
|
80
|
+
finished = Signal(str, bool)
|
|
81
|
+
#: Memory was read from a target: where it starts, the bytes, and the
|
|
82
|
+
#: target they came from.
|
|
83
|
+
memory_read = Signal(int, bytes, str)
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
events: EventLog,
|
|
88
|
+
backend: str = backends.DEFAULT,
|
|
89
|
+
parent: QObject | None = None,
|
|
90
|
+
) -> None:
|
|
91
|
+
super().__init__(parent)
|
|
92
|
+
self.events = events
|
|
93
|
+
self.backend: Backend = backends.get(backend)
|
|
94
|
+
self.connection: Connection | None = None
|
|
95
|
+
#: What is connected to, while something is: the target's name and
|
|
96
|
+
#: the probe's id.
|
|
97
|
+
self.target = ""
|
|
98
|
+
self.probe = ""
|
|
99
|
+
#: The memory of the target last asked about, connected or not.
|
|
100
|
+
self.regions: list[Region] = []
|
|
101
|
+
self._regions_for = ""
|
|
102
|
+
self.busy = False
|
|
103
|
+
self._cancel = threading.Event()
|
|
104
|
+
self._worker = Worker(self)
|
|
105
|
+
self._last_progress = -1.0
|
|
106
|
+
self._shut = False
|
|
107
|
+
|
|
108
|
+
# --- running things --------------------------------------------------------------
|
|
109
|
+
@property
|
|
110
|
+
def connected(self) -> bool:
|
|
111
|
+
return self.connection is not None
|
|
112
|
+
|
|
113
|
+
def _run(
|
|
114
|
+
self,
|
|
115
|
+
name: str,
|
|
116
|
+
work: Callable[[], Any],
|
|
117
|
+
done: Callable[[Any], None],
|
|
118
|
+
quiet: bool = False,
|
|
119
|
+
) -> bool:
|
|
120
|
+
"""Queue ``work`` for the worker, and ``done`` for its result.
|
|
121
|
+
|
|
122
|
+
``quiet`` is for looking things up, which is not something being done
|
|
123
|
+
to a target: it does not mark the flasher busy, so the buttons stay
|
|
124
|
+
as they are, and it may be asked for while something else runs.
|
|
125
|
+
Returns False, having said why, if it could not be started.
|
|
126
|
+
"""
|
|
127
|
+
if not quiet:
|
|
128
|
+
if self.busy:
|
|
129
|
+
self.events.warning(f"{name} was not started: something else is still running.")
|
|
130
|
+
return False
|
|
131
|
+
self._set_busy(True)
|
|
132
|
+
self._cancel.clear()
|
|
133
|
+
self._last_progress = -1.0
|
|
134
|
+
|
|
135
|
+
def job() -> tuple[Any, str | None, bool]:
|
|
136
|
+
# A backend's own error is a sentence already and is passed on as
|
|
137
|
+
# one. Anything else is a fault in pyflashgui or in a library,
|
|
138
|
+
# and says what kind it was.
|
|
139
|
+
try:
|
|
140
|
+
return work(), None, False
|
|
141
|
+
except CancelledError as exc:
|
|
142
|
+
return None, str(exc), True
|
|
143
|
+
except BackendError as exc:
|
|
144
|
+
return None, str(exc), False
|
|
145
|
+
|
|
146
|
+
def deliver(result, error: str | None) -> None:
|
|
147
|
+
cancelled = False
|
|
148
|
+
if error is None:
|
|
149
|
+
result, error, cancelled = result
|
|
150
|
+
if not quiet:
|
|
151
|
+
self._set_busy(False)
|
|
152
|
+
if error is not None:
|
|
153
|
+
(self.events.warning if cancelled else self.events.error)(error)
|
|
154
|
+
if not quiet:
|
|
155
|
+
self.finished.emit(name, False)
|
|
156
|
+
return
|
|
157
|
+
ok = done(result)
|
|
158
|
+
if not quiet:
|
|
159
|
+
self.finished.emit(name, ok is not False)
|
|
160
|
+
|
|
161
|
+
self._worker.submit(job, deliver)
|
|
162
|
+
return True
|
|
163
|
+
|
|
164
|
+
def _set_busy(self, busy: bool) -> None:
|
|
165
|
+
if busy != self.busy:
|
|
166
|
+
self.busy = busy
|
|
167
|
+
self.busy_changed.emit(busy)
|
|
168
|
+
|
|
169
|
+
def _progress(self, label: str) -> Callable[[float], None]:
|
|
170
|
+
"""A progress callback for the worker thread, saying this is what is done.
|
|
171
|
+
|
|
172
|
+
Emitting a signal from another thread is the one thing here that is
|
|
173
|
+
safe to do from it: Qt queues it for the GUI thread.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
def report(fraction: float) -> None:
|
|
177
|
+
fraction = max(0.0, min(1.0, float(fraction)))
|
|
178
|
+
if fraction >= 1.0 or fraction - self._last_progress >= PROGRESS_STEP:
|
|
179
|
+
self._last_progress = fraction if fraction < 1.0 else -1.0
|
|
180
|
+
self.progress.emit(label, fraction)
|
|
181
|
+
|
|
182
|
+
return report
|
|
183
|
+
|
|
184
|
+
def cancel(self) -> None:
|
|
185
|
+
"""Ask whatever is running to stop, if it is something that can."""
|
|
186
|
+
self._cancel.set()
|
|
187
|
+
|
|
188
|
+
# --- what is there ---------------------------------------------------------------
|
|
189
|
+
def set_backend(self, name: str) -> None:
|
|
190
|
+
"""Use another backend. Whatever was connected is let go of first."""
|
|
191
|
+
if name == self.backend.name:
|
|
192
|
+
return
|
|
193
|
+
if self.connected:
|
|
194
|
+
self.disconnect()
|
|
195
|
+
self.backend = backends.get(name)
|
|
196
|
+
self.regions, self._regions_for = [], ""
|
|
197
|
+
self.regions_changed.emit()
|
|
198
|
+
self.refresh_targets()
|
|
199
|
+
self.refresh_probes()
|
|
200
|
+
|
|
201
|
+
def refresh_probes(self) -> None:
|
|
202
|
+
backend = self.backend
|
|
203
|
+
self._run("Looking for probes", backend.probes, self.probes_changed.emit, quiet=True)
|
|
204
|
+
|
|
205
|
+
def refresh_targets(self) -> None:
|
|
206
|
+
backend = self.backend
|
|
207
|
+
self._run("Listing targets", backend.targets, self.targets_changed.emit, quiet=True)
|
|
208
|
+
|
|
209
|
+
def set_target(self, target: str) -> None:
|
|
210
|
+
"""Find out the memory of this target, as far as can be told unconnected."""
|
|
211
|
+
if self.connected or target == self._regions_for:
|
|
212
|
+
return
|
|
213
|
+
self._regions_for = target
|
|
214
|
+
backend = self.backend
|
|
215
|
+
|
|
216
|
+
def known(regions: list[Region]) -> None:
|
|
217
|
+
# Asked for and answered on different turns of the loop: a
|
|
218
|
+
# target picked since, or a connection made, has the say.
|
|
219
|
+
if not self.connected and self._regions_for == target:
|
|
220
|
+
self.regions = regions
|
|
221
|
+
self.regions_changed.emit()
|
|
222
|
+
|
|
223
|
+
self._run("Reading the target's memory map", lambda: backend.regions(target), known, True)
|
|
224
|
+
|
|
225
|
+
# --- connecting ------------------------------------------------------------------
|
|
226
|
+
def connect_to(
|
|
227
|
+
self,
|
|
228
|
+
probe: str,
|
|
229
|
+
target: str,
|
|
230
|
+
frequency: int | None = None,
|
|
231
|
+
interface: str | None = None,
|
|
232
|
+
) -> None:
|
|
233
|
+
backend = self.backend
|
|
234
|
+
|
|
235
|
+
def work() -> tuple[Connection, list[Region]]:
|
|
236
|
+
connection = backend.connect(probe, target, frequency, interface)
|
|
237
|
+
try:
|
|
238
|
+
return connection, connection.regions()
|
|
239
|
+
except Exception:
|
|
240
|
+
connection.close()
|
|
241
|
+
raise
|
|
242
|
+
|
|
243
|
+
def done(result: tuple[Connection, list[Region]]) -> None:
|
|
244
|
+
self.connection, self.regions = result
|
|
245
|
+
self.probe, self.target = probe, target
|
|
246
|
+
self._regions_for = target
|
|
247
|
+
self.events.good(f"Connected to {target} through probe {probe}.")
|
|
248
|
+
self.connection_changed.emit(True)
|
|
249
|
+
self.regions_changed.emit()
|
|
250
|
+
|
|
251
|
+
if not self._run(f"Connecting to {target}", work, done):
|
|
252
|
+
return
|
|
253
|
+
self.events.information(f"Connecting to {target} through probe {probe}...")
|
|
254
|
+
|
|
255
|
+
def disconnect(self) -> None:
|
|
256
|
+
connection, self.connection = self.connection, None
|
|
257
|
+
if connection is None:
|
|
258
|
+
return
|
|
259
|
+
target = self.target
|
|
260
|
+
self.connection_changed.emit(False)
|
|
261
|
+
self._run(
|
|
262
|
+
"Disconnecting",
|
|
263
|
+
connection.close,
|
|
264
|
+
lambda _r: self.events.information(f"Disconnected from {target}."),
|
|
265
|
+
quiet=True,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def _connection(self, name: str) -> Connection | None:
|
|
269
|
+
if self.connection is None:
|
|
270
|
+
self.events.warning(f"{name} needs a probe connected to a target first.")
|
|
271
|
+
return self.connection
|
|
272
|
+
|
|
273
|
+
# --- what is done to a target ----------------------------------------------------
|
|
274
|
+
def erase(self, ranges: Sequence[tuple[int, int]] | None = None) -> None:
|
|
275
|
+
"""Erase all of the target's flash, or these stretches of it.
|
|
276
|
+
|
|
277
|
+
Each of ``ranges`` is an address and a length.
|
|
278
|
+
"""
|
|
279
|
+
if (connection := self._connection("Erasing")) is None:
|
|
280
|
+
return
|
|
281
|
+
target = self.target
|
|
282
|
+
started = time.monotonic()
|
|
283
|
+
if ranges is None:
|
|
284
|
+
what = f"all of the flash of {target}"
|
|
285
|
+
work = connection.erase_chip
|
|
286
|
+
else:
|
|
287
|
+
asked = list(ranges)
|
|
288
|
+
what = ", ".join(f"{a:08X} to {a + n - 1:08X}" for a, n in asked) + f" of {target}"
|
|
289
|
+
|
|
290
|
+
def work() -> None:
|
|
291
|
+
connection.erase_ranges(asked)
|
|
292
|
+
|
|
293
|
+
def done(_result) -> None:
|
|
294
|
+
took = time.monotonic() - started
|
|
295
|
+
self.events.good(f"Erased {what} in {took:.1f} s.")
|
|
296
|
+
|
|
297
|
+
if self._run("Erasing", work, done):
|
|
298
|
+
self.events.information(f"Erasing {what}...")
|
|
299
|
+
self.progress.emit("Erasing", 0.0)
|
|
300
|
+
|
|
301
|
+
def program(
|
|
302
|
+
self,
|
|
303
|
+
images: Sequence[Image],
|
|
304
|
+
erase: str = ERASE_SECTORS,
|
|
305
|
+
verify: bool = True,
|
|
306
|
+
reset: bool = True,
|
|
307
|
+
) -> None:
|
|
308
|
+
"""Program these files, then verify and reset if asked to."""
|
|
309
|
+
if (connection := self._connection("Programming")) is None:
|
|
310
|
+
return
|
|
311
|
+
segments: list[Segment] = [s for image in images for s in image.segments]
|
|
312
|
+
if not segments:
|
|
313
|
+
self.events.warning("There is nothing to program: no file is ticked.")
|
|
314
|
+
return
|
|
315
|
+
size = sum(len(s) for s in segments)
|
|
316
|
+
started = time.monotonic()
|
|
317
|
+
|
|
318
|
+
def work() -> Verification | None:
|
|
319
|
+
connection.program(segments, erase=erase, progress=self._progress("Programming"))
|
|
320
|
+
result = None
|
|
321
|
+
if verify:
|
|
322
|
+
self._last_progress = -1.0
|
|
323
|
+
result = connection.verify(
|
|
324
|
+
segments, progress=self._progress("Verifying"), cancelled=self._cancel.is_set
|
|
325
|
+
)
|
|
326
|
+
# Not reset after a verify that failed: what is in the flash is
|
|
327
|
+
# not what was meant to be, and starting it helps nobody.
|
|
328
|
+
if reset and (result is None or result.ok):
|
|
329
|
+
connection.reset()
|
|
330
|
+
return result
|
|
331
|
+
|
|
332
|
+
def done(result: Verification | None) -> bool:
|
|
333
|
+
took = time.monotonic() - started
|
|
334
|
+
files = "1 file" if len(images) == 1 else f"{len(images)} files"
|
|
335
|
+
if result is not None and not result.ok:
|
|
336
|
+
self.events.error(
|
|
337
|
+
f"Programmed {files}, and the target does not hold what was sent. "
|
|
338
|
+
+ verification_text(result)
|
|
339
|
+
)
|
|
340
|
+
return False
|
|
341
|
+
parts = [f"Programmed {files}, {size_text(size)}"]
|
|
342
|
+
if result is not None:
|
|
343
|
+
parts.append("verified")
|
|
344
|
+
if reset:
|
|
345
|
+
parts.append("and reset the target")
|
|
346
|
+
self.events.good(", ".join(parts) + f", in {took:.1f} s.")
|
|
347
|
+
return True
|
|
348
|
+
|
|
349
|
+
if self._run("Programming", work, done):
|
|
350
|
+
names = ", ".join(image.name for image in images)
|
|
351
|
+
self.events.information(f"Programming {self.target} with {names}...")
|
|
352
|
+
|
|
353
|
+
def verify(self, images: Sequence[Image]) -> None:
|
|
354
|
+
if (connection := self._connection("Verifying")) is None:
|
|
355
|
+
return
|
|
356
|
+
segments: list[Segment] = [s for image in images for s in image.segments]
|
|
357
|
+
if not segments:
|
|
358
|
+
self.events.warning("There is nothing to verify: no file is ticked.")
|
|
359
|
+
return
|
|
360
|
+
|
|
361
|
+
def work() -> Verification:
|
|
362
|
+
return connection.verify(
|
|
363
|
+
segments, progress=self._progress("Verifying"), cancelled=self._cancel.is_set
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
def done(result: Verification) -> bool:
|
|
367
|
+
(self.events.good if result.ok else self.events.error)(verification_text(result))
|
|
368
|
+
return result.ok
|
|
369
|
+
|
|
370
|
+
self._run("Verifying", work, done)
|
|
371
|
+
|
|
372
|
+
def read_memory(self, address: int, length: int, skip_setup: bool = False) -> None:
|
|
373
|
+
"""Read this much of the target's memory, and say so with ``memory_read``.
|
|
374
|
+
|
|
375
|
+
Into memory and not into a file: most reads are to look at what is
|
|
376
|
+
there, and whoever shows it can save it once it has been looked at.
|
|
377
|
+
"""
|
|
378
|
+
if (connection := self._connection("Reading")) is None:
|
|
379
|
+
return
|
|
380
|
+
target = self.target
|
|
381
|
+
|
|
382
|
+
def work() -> bytes:
|
|
383
|
+
return connection.read_range(
|
|
384
|
+
address, length, self._progress("Reading"), self._cancel.is_set, skip_setup
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
def done(data: bytes) -> None:
|
|
388
|
+
self.events.good(f"Read {size_text(len(data))} at {address:08X} of {target}.")
|
|
389
|
+
self.memory_read.emit(address, data, target)
|
|
390
|
+
|
|
391
|
+
if self._run("Reading", work, done):
|
|
392
|
+
self.events.information(f"Reading {size_text(length)} at {address:08X} of {target}...")
|
|
393
|
+
|
|
394
|
+
def reset(self) -> None:
|
|
395
|
+
if (connection := self._connection("Resetting")) is None:
|
|
396
|
+
return
|
|
397
|
+
self._run(
|
|
398
|
+
"Resetting",
|
|
399
|
+
connection.reset,
|
|
400
|
+
lambda _r: self.events.information(f"Reset {self.target}."),
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
# --- going away ------------------------------------------------------------------
|
|
404
|
+
def shutdown(self) -> None:
|
|
405
|
+
"""Let go of the probe and stop the worker. For when the window closes."""
|
|
406
|
+
if self._shut:
|
|
407
|
+
return # a window asked to close twice closes once
|
|
408
|
+
self._shut = True
|
|
409
|
+
self._cancel.set()
|
|
410
|
+
connection, self.connection = self.connection, None
|
|
411
|
+
self._worker.stop()
|
|
412
|
+
if connection is not None:
|
|
413
|
+
# After the worker has stopped, so nothing is half way through
|
|
414
|
+
# using it, and here rather than queued, so that it happens
|
|
415
|
+
# before the application has gone.
|
|
416
|
+
try:
|
|
417
|
+
connection.close()
|
|
418
|
+
except BackendError:
|
|
419
|
+
pass
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 davhodg
|
|
3
|
+
"""Firmware images: ELF, Intel HEX, Motorola S-record and raw binary.
|
|
4
|
+
|
|
5
|
+
Reading and writing is bincopy's job. What is here is the part bincopy cannot decide:
|
|
6
|
+
which format a file is, what its address is, and what to do when it does not
|
|
7
|
+
have one.
|
|
8
|
+
|
|
9
|
+
Every format is read the same way and comes out as the same thing -- runs of
|
|
10
|
+
bytes, each at an address -- so that the memory map and whatever does the
|
|
11
|
+
programming are shown exactly the bytes that were read, rather than each
|
|
12
|
+
reading the file again in its own way and the two disagreeing.
|
|
13
|
+
|
|
14
|
+
An ELF, a hex or an S-record file carries the address of every byte in it. A
|
|
15
|
+
raw binary does not -- it is bytes and nothing else -- so the address has to
|
|
16
|
+
be supplied before it can be put anywhere, and an image loaded without one
|
|
17
|
+
says so rather than guessing at zero.
|
|
18
|
+
|
|
19
|
+
**An ELF is read at its load addresses.** Initialised data is linked to run
|
|
20
|
+
from RAM and stored in flash, and the two addresses are both in the file. The
|
|
21
|
+
load address is where the bytes have to be programmed; the other is where
|
|
22
|
+
start-up code copies them to, which is no business of a programmer's.
|
|
23
|
+
|
|
24
|
+
Gaps are left as they are found. Padding the holes in a file would mean
|
|
25
|
+
writing bytes it never contained, over whatever the target had there.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import re
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
# bincopy is needed only when somebody picks a firmware file, so it is
|
|
35
|
+
# imported in the function that reads one.
|
|
36
|
+
|
|
37
|
+
ELF = "ELF"
|
|
38
|
+
IHEX = "Intel HEX"
|
|
39
|
+
SREC = "Motorola S-record"
|
|
40
|
+
BINARY = "raw binary"
|
|
41
|
+
|
|
42
|
+
#: Suffixes that mean "this file has no addresses in it".
|
|
43
|
+
BINARY_SUFFIXES = {".bin", ".raw", ".img", ".rom", ".dat"}
|
|
44
|
+
|
|
45
|
+
#: For the file dialogs. One "any of them" entry first, because guessing the
|
|
46
|
+
#: format from the contents is what the reader does anyway.
|
|
47
|
+
READ_FILTER = (
|
|
48
|
+
"Firmware images (*.elf *.axf *.out *.hex *.ihex *.ihx *.s19 *.s28 *.s37 *.srec *.mot "
|
|
49
|
+
"*.bin *.raw *.img);;"
|
|
50
|
+
"ELF (*.elf *.axf *.out);;"
|
|
51
|
+
"Intel HEX (*.hex *.ihex *.ihx);;"
|
|
52
|
+
"Motorola S-record (*.s19 *.s28 *.s37 *.srec *.mot);;"
|
|
53
|
+
"Raw binary (*.bin *.raw *.img);;"
|
|
54
|
+
"All files (*)"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
#: S-record address widths, by the suffix that names them. S19 is 16-bit
|
|
58
|
+
#: addresses, S28 24-bit, S37 32-bit; the suffix is the usual way of saying so.
|
|
59
|
+
SREC_SUFFIXES = {".s19": 16, ".s28": 24, ".s37": 32, ".srec": 32, ".mot": 32, ".sre": 32}
|
|
60
|
+
IHEX_SUFFIXES = {".hex", ".ihex", ".ihx", ".i32", ".a90"}
|
|
61
|
+
|
|
62
|
+
#: For saving what was read back. Raw binary first: it is what was read,
|
|
63
|
+
#: with nothing added.
|
|
64
|
+
WRITE_FILTER = (
|
|
65
|
+
"Raw binary (*.bin);;Intel HEX (*.hex);;Motorola S-record (*.s19 *.s28 *.s37);;All files (*)"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
#: How an ELF file starts, whatever it is called.
|
|
69
|
+
_ELF_MAGIC = b"\x7fELF"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ImageError(Exception):
|
|
73
|
+
"""The file could not be read, or could be read but not placed."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class Segment:
|
|
78
|
+
"""One run of contiguous bytes at a known address."""
|
|
79
|
+
|
|
80
|
+
address: int
|
|
81
|
+
data: bytes
|
|
82
|
+
|
|
83
|
+
def __len__(self) -> int:
|
|
84
|
+
return len(self.data)
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def end(self) -> int:
|
|
88
|
+
"""The address after the last byte."""
|
|
89
|
+
return self.address + len(self.data)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class Image:
|
|
94
|
+
"""What a firmware file turned out to contain."""
|
|
95
|
+
|
|
96
|
+
path: str
|
|
97
|
+
format: str # one of ELF, IHEX, SREC, BINARY
|
|
98
|
+
segments: tuple[Segment, ...]
|
|
99
|
+
#: Lines of an Intel HEX file that were not records -- comments, a title
|
|
100
|
+
#: -- and so were passed over. Said in the summary, so it is never silent.
|
|
101
|
+
ignored: int = 0
|
|
102
|
+
#: Where execution starts, for a file that says: an ELF always does, and
|
|
103
|
+
#: a hex or S-record file may. None for one that does not.
|
|
104
|
+
entry: int | None = None
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def name(self) -> str:
|
|
108
|
+
return Path(self.path).name
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def size(self) -> int:
|
|
112
|
+
return sum(len(s) for s in self.segments)
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def address(self) -> int:
|
|
116
|
+
return self.segments[0].address if self.segments else 0
|
|
117
|
+
|
|
118
|
+
def summary(self) -> str:
|
|
119
|
+
"""One line: what was read, how much of it, and where it goes."""
|
|
120
|
+
if not self.segments:
|
|
121
|
+
return f"{self.name}: {self.format}, empty"
|
|
122
|
+
where = ", ".join(f"{s.address:08X}+{len(s)}" for s in self.segments[:4])
|
|
123
|
+
if len(self.segments) > 4:
|
|
124
|
+
where += f", and {len(self.segments) - 4} more"
|
|
125
|
+
segments = "1 segment" if len(self.segments) == 1 else f"{len(self.segments)} segments"
|
|
126
|
+
line = f"{self.name}: {self.format}, {self.size} bytes, {segments} [{where}]"
|
|
127
|
+
if self.ignored:
|
|
128
|
+
lines = "1 line" if self.ignored == 1 else f"{self.ignored} lines"
|
|
129
|
+
line += f"; {lines} not starting with ':' ignored"
|
|
130
|
+
return line
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def looks_binary(path: str) -> bool:
|
|
134
|
+
"""Whether choosing this file will need an address typed in.
|
|
135
|
+
|
|
136
|
+
Answered from the name, because a pane has to enable or disable the
|
|
137
|
+
address box the moment a file is picked; reading settles it properly.
|
|
138
|
+
"""
|
|
139
|
+
return Path(path).suffix.lower() in BINARY_SUFFIXES
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
#: An Intel HEX record: a colon, then hex digits -- at least a length, an
|
|
143
|
+
#: address, a type and a checksum.
|
|
144
|
+
_IHEX_RECORD = re.compile(rb"^:[0-9A-Fa-f]{10,}$")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _sniff(raw: bytes) -> str | None:
|
|
148
|
+
"""Which format carries its own addresses here, or None for none of them.
|
|
149
|
+
|
|
150
|
+
From the contents, not the name: a build that calls its ELF ``firmware``
|
|
151
|
+
or its hex file ``image.txt`` has still made an ELF and a hex file.
|
|
152
|
+
"""
|
|
153
|
+
if raw.startswith(_ELF_MAGIC):
|
|
154
|
+
return ELF
|
|
155
|
+
for line in raw.splitlines():
|
|
156
|
+
text = line.strip()
|
|
157
|
+
if not text:
|
|
158
|
+
continue
|
|
159
|
+
if text.startswith(b":"):
|
|
160
|
+
return IHEX
|
|
161
|
+
if text[:1] in (b"S", b"s") and text[1:2].isdigit():
|
|
162
|
+
return SREC
|
|
163
|
+
break
|
|
164
|
+
# Intel HEX that opens with a comment or a title. Every record starts with
|
|
165
|
+
# a colon, so anything else in a text file of records is not data.
|
|
166
|
+
try:
|
|
167
|
+
lines = raw.decode("ascii").splitlines()
|
|
168
|
+
except UnicodeDecodeError:
|
|
169
|
+
return None
|
|
170
|
+
if any(_IHEX_RECORD.match(line.strip().encode()) for line in lines):
|
|
171
|
+
return IHEX
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _ihex_records(text: str) -> tuple[str, int]:
|
|
176
|
+
"""The records of an Intel HEX file, and how many other lines there were."""
|
|
177
|
+
records, ignored = [], 0
|
|
178
|
+
for line in text.splitlines():
|
|
179
|
+
stripped = line.strip()
|
|
180
|
+
if stripped.startswith(":"):
|
|
181
|
+
records.append(stripped)
|
|
182
|
+
elif stripped:
|
|
183
|
+
ignored += 1
|
|
184
|
+
return "\n".join(records) + "\n", ignored
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def read(path: str, address: int | None = None) -> Image:
|
|
188
|
+
"""Read a firmware file, whatever format it is in.
|
|
189
|
+
|
|
190
|
+
`address` is where a raw binary starts. It is required for one, ignored
|
|
191
|
+
for a file that carries its own addresses -- there the file is right and a
|
|
192
|
+
typed-in number would only be a way to get it wrong.
|
|
193
|
+
"""
|
|
194
|
+
try:
|
|
195
|
+
raw = Path(path).read_bytes()
|
|
196
|
+
except OSError as exc:
|
|
197
|
+
raise ImageError(str(exc)) from exc
|
|
198
|
+
if not raw:
|
|
199
|
+
raise ImageError("the file is empty")
|
|
200
|
+
|
|
201
|
+
import bincopy
|
|
202
|
+
|
|
203
|
+
binfile = bincopy.BinFile()
|
|
204
|
+
fmt = _sniff(raw)
|
|
205
|
+
ignored = 0
|
|
206
|
+
if fmt == ELF:
|
|
207
|
+
try:
|
|
208
|
+
binfile.add_elf(raw)
|
|
209
|
+
except Exception as exc:
|
|
210
|
+
# Not bincopy's own error alone: a damaged ELF fails inside the
|
|
211
|
+
# ELF reader, in whichever way the damage takes it.
|
|
212
|
+
raise ImageError(f"not readable as {fmt}: {exc}") from exc
|
|
213
|
+
if not binfile.segments:
|
|
214
|
+
raise ImageError("this ELF has nothing in it to program: no loadable section")
|
|
215
|
+
elif fmt is not None:
|
|
216
|
+
try:
|
|
217
|
+
text = raw.decode("ascii", "strict")
|
|
218
|
+
if fmt == IHEX:
|
|
219
|
+
text, ignored = _ihex_records(text)
|
|
220
|
+
binfile.add_ihex(text)
|
|
221
|
+
else:
|
|
222
|
+
binfile.add_srec(text)
|
|
223
|
+
except (UnicodeDecodeError, bincopy.Error, ValueError) as exc:
|
|
224
|
+
raise ImageError(f"not readable as {fmt}: {exc}") from exc
|
|
225
|
+
else:
|
|
226
|
+
if address is None:
|
|
227
|
+
raise ImageError(
|
|
228
|
+
"this file is raw binary -- it carries no addresses, so the "
|
|
229
|
+
"start address has to be supplied"
|
|
230
|
+
)
|
|
231
|
+
fmt = BINARY
|
|
232
|
+
binfile.add_binary(raw, address=address)
|
|
233
|
+
|
|
234
|
+
segments = tuple(Segment(s.address, bytes(s.data)) for s in binfile.segments)
|
|
235
|
+
return Image(
|
|
236
|
+
path=path,
|
|
237
|
+
format=fmt,
|
|
238
|
+
segments=segments,
|
|
239
|
+
ignored=ignored,
|
|
240
|
+
entry=binfile.execution_start_address,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _srec_width(suffix: str, address: int, size: int) -> int:
|
|
245
|
+
"""How wide the addresses of an S-record file have to be.
|
|
246
|
+
|
|
247
|
+
What the suffix asks for, or wider where the addresses will not fit:
|
|
248
|
+
flash at 08000000 saved as .s19 is given 32-bit addresses rather
|
|
249
|
+
than refused, since a file that holds the data is what was wanted.
|
|
250
|
+
"""
|
|
251
|
+
top = address + size
|
|
252
|
+
needed = 16 if top <= 0x1_0000 else 24 if top <= 0x100_0000 else 32
|
|
253
|
+
return max(SREC_SUFFIXES.get(suffix, needed), needed)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def write(path: str, address: int, data: bytes) -> str:
|
|
257
|
+
"""Write bytes read back from a target, in the format the name asks for.
|
|
258
|
+
|
|
259
|
+
Exactly what was read, at the address it was read from: nothing padded
|
|
260
|
+
and nothing filled in. Returns the format used, for the log.
|
|
261
|
+
"""
|
|
262
|
+
import bincopy
|
|
263
|
+
|
|
264
|
+
binfile = bincopy.BinFile()
|
|
265
|
+
binfile.add_binary(data, address=address)
|
|
266
|
+
suffix = Path(path).suffix.lower()
|
|
267
|
+
try:
|
|
268
|
+
if suffix in IHEX_SUFFIXES:
|
|
269
|
+
fmt, text = IHEX, binfile.as_ihex()
|
|
270
|
+
elif suffix in SREC_SUFFIXES:
|
|
271
|
+
width = _srec_width(suffix, address, len(data))
|
|
272
|
+
fmt, text = SREC, binfile.as_srec(address_length_bits=width)
|
|
273
|
+
else:
|
|
274
|
+
Path(path).write_bytes(data)
|
|
275
|
+
return BINARY
|
|
276
|
+
Path(path).write_text(text, encoding="ascii")
|
|
277
|
+
except (OSError, bincopy.Error, ValueError) as exc:
|
|
278
|
+
raise ImageError(str(exc)) from exc
|
|
279
|
+
return fmt
|