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.
Files changed (68) hide show
  1. pyflashgui/__init__.py +6 -0
  2. pyflashgui/__main__.py +334 -0
  3. pyflashgui/backends/__init__.py +40 -0
  4. pyflashgui/backends/base.py +272 -0
  5. pyflashgui/backends/jlink_backend.py +596 -0
  6. pyflashgui/backends/pyocd_backend.py +271 -0
  7. pyflashgui/backends/simulated.py +240 -0
  8. pyflashgui/core/__init__.py +2 -0
  9. pyflashgui/core/checkout.py +134 -0
  10. pyflashgui/core/compare.py +157 -0
  11. pyflashgui/core/context.py +37 -0
  12. pyflashgui/core/events.py +71 -0
  13. pyflashgui/core/excepthook.py +110 -0
  14. pyflashgui/core/flasher.py +419 -0
  15. pyflashgui/core/images.py +279 -0
  16. pyflashgui/core/installation.py +137 -0
  17. pyflashgui/core/layout.py +72 -0
  18. pyflashgui/core/memory_map.py +234 -0
  19. pyflashgui/core/packages.py +104 -0
  20. pyflashgui/core/paths.py +49 -0
  21. pyflashgui/core/settings.py +44 -0
  22. pyflashgui/core/shortcut.py +154 -0
  23. pyflashgui/core/slow_start.py +101 -0
  24. pyflashgui/core/timing.py +312 -0
  25. pyflashgui/core/updates.py +195 -0
  26. pyflashgui/core/worker.py +71 -0
  27. pyflashgui/core/workspaces.py +250 -0
  28. pyflashgui/help/__init__.py +129 -0
  29. pyflashgui/help/about.md +21 -0
  30. pyflashgui/help/changelog.md +46 -0
  31. pyflashgui/help/files.md +60 -0
  32. pyflashgui/help/main-window.png +0 -0
  33. pyflashgui/help/manual.md +44 -0
  34. pyflashgui/help/memory-map.md +28 -0
  35. pyflashgui/help/memory.md +59 -0
  36. pyflashgui/help/probe.md +71 -0
  37. pyflashgui/resources/__init__.py +10 -0
  38. pyflashgui/resources/pyflashgui.ico +0 -0
  39. pyflashgui/resources/pyflashgui.png +0 -0
  40. pyflashgui/resources/pyflashgui.svg +32 -0
  41. pyflashgui/resources/pyflashgui_small.svg +20 -0
  42. pyflashgui/ui/__init__.py +2 -0
  43. pyflashgui/ui/confirm.py +312 -0
  44. pyflashgui/ui/detached.py +101 -0
  45. pyflashgui/ui/erase_dialog.py +141 -0
  46. pyflashgui/ui/event_colours.py +66 -0
  47. pyflashgui/ui/files_view.py +716 -0
  48. pyflashgui/ui/folders.py +176 -0
  49. pyflashgui/ui/fonts.py +75 -0
  50. pyflashgui/ui/help_menu.py +587 -0
  51. pyflashgui/ui/main_window.py +754 -0
  52. pyflashgui/ui/map_view.py +215 -0
  53. pyflashgui/ui/memory_view.py +724 -0
  54. pyflashgui/ui/messages.py +78 -0
  55. pyflashgui/ui/pane_bar.py +92 -0
  56. pyflashgui/ui/panes.py +1068 -0
  57. pyflashgui/ui/persist.py +132 -0
  58. pyflashgui/ui/probe_bar.py +315 -0
  59. pyflashgui/ui/read_dialog.py +161 -0
  60. pyflashgui/ui/session.py +53 -0
  61. pyflashgui/ui/theme.py +85 -0
  62. pyflashgui-0.1.0.dist-info/METADATA +80 -0
  63. pyflashgui-0.1.0.dist-info/RECORD +68 -0
  64. pyflashgui-0.1.0.dist-info/WHEEL +5 -0
  65. pyflashgui-0.1.0.dist-info/entry_points.txt +2 -0
  66. pyflashgui-0.1.0.dist-info/licenses/LICENSE +202 -0
  67. pyflashgui-0.1.0.dist-info/licenses/NOTICE +30 -0
  68. 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