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
pyflashgui/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 davhodg
3
+ """pyflashgui — a graphical flash programming tool."""
4
+
5
+ __version__ = "0.1.0"
6
+ APP_NAME = "pyflashgui" # single place to change the display name
pyflashgui/__main__.py ADDED
@@ -0,0 +1,334 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 davhodg
3
+ """Entry point: `python -m pyflashgui`, or the `pyflashgui` command pip installs."""
4
+
5
+ # Deliberately out of order (hence the noqa): timing's clock starts when it is
6
+ # imported, so it has to come before the imports whose cost is being measured.
7
+ # It pulls in nothing but the standard library.
8
+ import importlib # noqa: I001
9
+ import os
10
+ import sys
11
+
12
+ from pyflashgui.core import timing
13
+ from PySide6.QtWidgets import QApplication
14
+
15
+ from pyflashgui import APP_NAME
16
+
17
+ #: Imported by ``--selftest``. These are the modules a packaged build is most
18
+ #: likely to be missing, because they are loaded by name at run time and so no
19
+ #: static analysis can see them.
20
+ SELFTEST_MODULES = (
21
+ "bincopy",
22
+ "pyocd",
23
+ "pyocd.core.helpers",
24
+ "pyocd.flash.file_programmer",
25
+ "pyocd.flash.eraser",
26
+ "cmsis_pack_manager",
27
+ "usb.core",
28
+ "libusb_package",
29
+ "pyflashgui.core.images",
30
+ "pyflashgui.core.memory_map",
31
+ "pyflashgui.backends.pyocd_backend",
32
+ "pyflashgui.backends.jlink_backend",
33
+ "pyflashgui.backends.simulated",
34
+ "pyflashgui.ui.main_window",
35
+ )
36
+
37
+ #: The probe drivers pyOCD has to be able to find. It looks them up through
38
+ #: entry points, which a build that left pyOCD's package metadata behind
39
+ #: does not have: every one of them would then be missing, with no error.
40
+ #: Not the J-Link driver, which pyOCD only offers where SEGGER's own library
41
+ #: is installed, and that is the user's to install.
42
+ PROBE_DRIVERS = ("cmsisdap", "picoprobe", "stlink")
43
+
44
+ #: Targets that are part of pyOCD itself rather than a CMSIS-Pack, and so
45
+ #: have to work in a build with no packs installed.
46
+ BUILTIN_TARGETS = ("cortex_m", "rp2040", "rp2350")
47
+
48
+
49
+ def missing_probe_drivers() -> list[str]:
50
+ """The PROBE_DRIVERS pyOCD cannot load here."""
51
+ from pyocd.probe.aggregator import PROBE_CLASSES
52
+
53
+ return [name for name in PROBE_DRIVERS if name not in PROBE_CLASSES]
54
+
55
+
56
+ def missing_builtin_targets() -> list[str]:
57
+ """The BUILTIN_TARGETS this pyOCD does not have."""
58
+ from pyocd.target import builtin
59
+
60
+ return [name for name in BUILTIN_TARGETS if name not in builtin.BUILTIN_TARGETS]
61
+
62
+
63
+ def selftest() -> int:
64
+ """Import everything a build could plausibly be missing. Exit code only:
65
+ a windowed build has nowhere to print to."""
66
+ failures = []
67
+ for name in SELFTEST_MODULES:
68
+ try:
69
+ importlib.import_module(name)
70
+ except Exception as exc:
71
+ failures.append(f"{name}: {exc}")
72
+
73
+ try:
74
+ failures += [f"pyOCD probe driver {name}: not found" for name in missing_probe_drivers()]
75
+ failures += [f"pyOCD target {name}: not found" for name in missing_builtin_targets()]
76
+ except Exception as exc:
77
+ failures.append(f"pyocd: {exc}")
78
+
79
+ # libusb is a native library carried inside a Python package, which is
80
+ # the kind of file a build leaves behind. Without it pyOCD lists no
81
+ # CMSIS-DAP v2 probe and no ST-Link, and says nothing about why.
82
+ try:
83
+ import libusb_package
84
+
85
+ if libusb_package.get_libusb1_backend() is None:
86
+ failures.append("libusb: the library libusb-package carries would not load")
87
+ except Exception as exc:
88
+ failures.append(f"libusb: {exc}")
89
+
90
+ # The manual is package data, so it is exactly the kind of file a build
91
+ # drops silently. Every page, by name: one missing page is a topic that
92
+ # vanished, and a manual that still opens is the worst way for that to
93
+ # be found.
94
+ try:
95
+ from pyflashgui.help import missing_images, missing_pages
96
+
97
+ for page in missing_pages():
98
+ failures.append(f"pyflashgui/help/{page}: not shipped with this build")
99
+ for image in missing_images():
100
+ failures.append(f"pyflashgui/help/{image}: shown in the manual but not shipped")
101
+ except Exception as exc:
102
+ failures.append(f"pyflashgui.help: {exc}")
103
+
104
+ # Help > Licences reads these at run time from beside the executable. A
105
+ # build that ships them where _find cannot see them would show three empty
106
+ # tabs, and no import check would notice.
107
+ try:
108
+ from pyflashgui.ui.help_menu import missing_licence_files
109
+
110
+ failures += [
111
+ f"{name}: not found beside the application"
112
+ for name in missing_licence_files(getattr(sys, "frozen", False))
113
+ ]
114
+ except Exception as exc:
115
+ failures.append(f"pyflashgui.ui.help_menu: {exc}")
116
+
117
+ if failures:
118
+ sys.stderr.write("selftest failures:" + "".join(f"\n {f}" for f in failures) + "\n")
119
+ return 1
120
+ return 0
121
+
122
+
123
+ #: Who Windows thinks the windows belong to. Without one, a pyflashgui started
124
+ #: from source is grouped under pythonw.exe and the taskbar shows Python's
125
+ #: icon whatever the window says -- the window icon only reaches the title bar.
126
+ APP_USER_MODEL_ID = "davhodg.pyflashgui"
127
+
128
+
129
+ def set_icon(app) -> None:
130
+ """The application icon, before any window exists.
131
+
132
+ Set on the application rather than on the main window so that a message
133
+ shown during start-up carries it too. The .ico rather than the PNG:
134
+ it holds each size drawn for that size, and Qt picks the nearest one.
135
+ """
136
+ from PySide6.QtGui import QIcon
137
+
138
+ from pyflashgui import resources
139
+
140
+ if sys.platform == "win32":
141
+ try:
142
+ import ctypes
143
+
144
+ ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(APP_USER_MODEL_ID)
145
+ except (AttributeError, OSError): # an unusual shell: the title bar still has it
146
+ pass
147
+ app.setWindowIcon(QIcon(str(resources.path("pyflashgui.ico"))))
148
+
149
+
150
+ #: The libraries Qt's X11 plugin needs that Debian, Ubuntu and Linux Mint can
151
+ #: leave out, each with the package it comes in. Without one, Qt stops with
152
+ #: "could not load the Qt platform plugin xcb" and does not say which.
153
+ XCB_LIBRARIES = (
154
+ ("libxcb-cursor.so.0", "libxcb-cursor0"),
155
+ ("libxcb-icccm.so.4", "libxcb-icccm4"),
156
+ ("libxcb-image.so.0", "libxcb-image0"),
157
+ ("libxcb-keysyms.so.1", "libxcb-keysyms1"),
158
+ ("libxcb-render-util.so.0", "libxcb-render-util0"),
159
+ ("libxkbcommon-x11.so.0", "libxkbcommon-x11-0"),
160
+ )
161
+
162
+
163
+ def uses_xcb(environ=None) -> bool:
164
+ """Whether Qt will draw through X11 here, where XCB_LIBRARIES are needed."""
165
+ if not sys.platform.startswith("linux"):
166
+ return False
167
+ environ = os.environ if environ is None else environ
168
+ chosen = environ.get("QT_QPA_PLATFORM", "")
169
+ if chosen:
170
+ return chosen.startswith("xcb")
171
+ return environ.get("XDG_SESSION_TYPE") != "wayland"
172
+
173
+
174
+ def missing_xcb_packages(load=None) -> list[str]:
175
+ """The packages to install for the XCB_LIBRARIES that cannot be loaded."""
176
+ if load is None:
177
+ import ctypes
178
+
179
+ load = ctypes.CDLL
180
+ missing = []
181
+ for library, package in XCB_LIBRARIES:
182
+ try:
183
+ load(library)
184
+ except OSError:
185
+ missing.append(package)
186
+ return missing
187
+
188
+
189
+ def main() -> int:
190
+ if "--selftest" in sys.argv:
191
+ return selftest()
192
+ if uses_xcb() and (missing := missing_xcb_packages()):
193
+ sys.stderr.write(
194
+ "Qt needs libraries this system does not have to draw the window. On Debian,\n"
195
+ "Ubuntu and Linux Mint:\n\n"
196
+ f" sudo apt install {' '.join(missing)}\n"
197
+ )
198
+ return 1
199
+ app = QApplication.instance() or QApplication(sys.argv)
200
+ app.setApplicationName(APP_NAME)
201
+ app.setOrganizationName(APP_NAME) # QSettings uses these two for the registry/ini path
202
+ set_icon(app)
203
+ # Before anything is drawn, so the first window is already in the colours chosen.
204
+ from pyflashgui.ui import theme
205
+
206
+ theme.apply(theme.chosen())
207
+ timing.mark("Qt started")
208
+ # From here on, which package each imported second belongs to. Installed
209
+ # after Qt because Qt is already in by now, and its cost is its own line.
210
+ timing.watch_imports()
211
+
212
+ # confirm.py is cheap -- Qt widgets and nothing else -- and has to come
213
+ # before the rest of the imports, because it is what covers them.
214
+ from pyflashgui.ui.confirm import accept_notice
215
+
216
+ loaded: dict = {}
217
+
218
+ def load() -> None:
219
+ """The slow half of starting up, done behind the notice.
220
+
221
+ Loading the libraries with nothing on screen is how a tool comes to
222
+ feel heavy. Behind a dialog somebody is reading, it is free. Run on
223
+ a thread of its own, so imports only: the window is made on the GUI
224
+ thread after.
225
+
226
+ Imported here rather than at the top of the file for a second reason
227
+ as well: a library which is not installed becomes a dialog. Started
228
+ from pyflashgui.cmd there is no console -- it runs pythonw -- so an
229
+ ImportError on the way up is a window that never appears and not one
230
+ word about why.
231
+ """
232
+ try:
233
+ from pyflashgui.ui.main_window import MainWindow
234
+ from pyflashgui.ui.session import Session
235
+
236
+ loaded["classes"] = (Session, MainWindow)
237
+ timing.mark(timing.LIBRARIES_STEP)
238
+ except Exception as exc:
239
+ # Not ImportError alone. A library that is installed and will
240
+ # not load fails however it likes, and anything not caught
241
+ # here is a window that never appears.
242
+ loaded["error"] = exc
243
+
244
+ # Before the window is built, and therefore before anything can reach
245
+ # for a probe: a notice read after that is a notice that was too late.
246
+ if not accept_notice(while_shown=load):
247
+ return 0
248
+ # Its own line, because the libraries load behind the notice and the
249
+ # window is built after it: without this, every step between the two
250
+ # carries however long somebody took to read the notice and press
251
+ # Continue, and the report blames the first thing the window does.
252
+ timing.mark(timing.NOTICE_STEP)
253
+ if (failed := loaded.get("error")) is not None:
254
+ from pyflashgui.ui import messages
255
+
256
+ messages.critical(None, f"{APP_NAME} cannot start", why_it_cannot_start(failed))
257
+ sys.stderr and sys.stderr.write(why_it_cannot_start(failed) + "\n")
258
+ return 1
259
+
260
+ # Held by a Session rather than a local, because switching workspace
261
+ # replaces the window rather than reconfiguring it.
262
+ session_class, window_class = loaded["classes"]
263
+ session = session_class(window_class)
264
+ window = session.open()
265
+ timing.mark("window on screen")
266
+ note_a_slow_start(window)
267
+ return app.exec()
268
+
269
+
270
+ def why_it_cannot_start(failed: BaseException) -> str:
271
+ """What to tell somebody whose libraries would not load, and what to do.
272
+
273
+ Two different faults. A library that is not there is put right by
274
+ installing it. One that is there and will not load is usually two
275
+ libraries that no longer agree -- a new release of one, which the other
276
+ was not written for -- and installing again changes nothing: it wants the
277
+ name of the file that failed, so that the pair can be found.
278
+ """
279
+ if isinstance(failed, ModuleNotFoundError):
280
+ return (
281
+ f"A library {APP_NAME} needs is missing:\n\n {failed}\n\n"
282
+ "Start it with pyflashgui.cmd (or pyflashgui.sh), which installs "
283
+ "anything missing before starting, or install it with pip."
284
+ )
285
+ import traceback
286
+
287
+ where = traceback.extract_tb(failed.__traceback__)
288
+ place = f"\n in {where[-1].filename}, line {where[-1].lineno}" if where else ""
289
+ return (
290
+ f"A library {APP_NAME} needs is installed but would not load:\n\n"
291
+ f" {type(failed).__name__}: {failed}{place}\n\n"
292
+ "This is usually two libraries that no longer agree, after a new release "
293
+ "of one of them. Upgrading the one named above, or going back a version "
294
+ "of the one it was loading, normally puts it right; in a source folder, "
295
+ "deleting .venv and starting the launcher installs them all again."
296
+ )
297
+
298
+
299
+ def note_a_slow_start(window) -> None:
300
+ """What starting had to do and how long it took, as far as it is worth saying.
301
+
302
+ Two separate lines, each only when there is something to say: how many
303
+ files Python compiled, and -- for a slow start -- what took over a second,
304
+ with a pointer to *Help > Diagnostics*, which always has the whole report.
305
+ ``--timing`` puts the whole report in the Event Log after every start.
306
+ """
307
+ from pyflashgui.core import slow_start
308
+
309
+ if (compiled := slow_start.compiled_note(timing.started_at())) is not None:
310
+ window.events.information(compiled)
311
+ slow = slow_start.message(
312
+ timing.total_work(), steps=timing.step_times(), packages=timing.import_times()
313
+ )
314
+ if slow is not None:
315
+ window.events.information(slow)
316
+ if timing.enabled():
317
+ report(window)
318
+
319
+
320
+ def report(window) -> None:
321
+ """Say where the time went, in the Event Log and in a file.
322
+
323
+ Both, because the launcher starts pythonw and there is no console to
324
+ print to, and because a number somebody has to read off the screen is a
325
+ number that does not reach a bug report.
326
+ """
327
+ for line in [timing.why_shown(), *timing.report_lines()]:
328
+ window.events.information(line)
329
+ if (path := timing.write_report(window.ctx.user_dir)) is not None:
330
+ window.events.information(f"Startup timing written to {path}")
331
+
332
+
333
+ if __name__ == "__main__":
334
+ sys.exit(main())
@@ -0,0 +1,40 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 davhodg
3
+ """The ways pyflashgui has of reaching a probe, by name.
4
+
5
+ A backend is made when it is first asked for and kept, so that there is one
6
+ of each: the simulated one holds what has been programmed into it, and two
7
+ of them would be two different sets of boards.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pyflashgui.backends.base import Backend, BackendError
13
+
14
+ #: What a workspace uses when it has not said.
15
+ DEFAULT = "pyocd"
16
+
17
+ #: In the order they are offered.
18
+ NAMES = ("pyocd", "jlink", "simulated")
19
+
20
+ _made: dict[str, Backend] = {}
21
+
22
+
23
+ def get(name: str = DEFAULT) -> Backend:
24
+ """The backend of this name. Imported here, and only when asked for."""
25
+ if name not in _made:
26
+ if name == "pyocd":
27
+ from pyflashgui.backends.pyocd_backend import PyocdBackend
28
+
29
+ _made[name] = PyocdBackend()
30
+ elif name == "jlink":
31
+ from pyflashgui.backends.jlink_backend import JLinkBackend
32
+
33
+ _made[name] = JLinkBackend()
34
+ elif name == "simulated":
35
+ from pyflashgui.backends.simulated import SimulatedBackend
36
+
37
+ _made[name] = SimulatedBackend()
38
+ else:
39
+ raise BackendError(f"There is no backend called {name}.")
40
+ return _made[name]
@@ -0,0 +1,272 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 davhodg
3
+ """What pyflashgui asks of whatever talks to a probe.
4
+
5
+ Two objects. A ``Backend`` knows what is plugged in and which targets it can
6
+ handle, and none of that needs anything connected. A ``Connection`` is one
7
+ probe attached to one target, and is what erases, programs and reads.
8
+
9
+ The rest of pyflashgui is written against these and never against a
10
+ flashing library, for two reasons. A second library can be added behind the
11
+ same interface without a pane noticing; and the simulated backend stands in
12
+ for a probe in the tests, and on a desk with no hardware on it.
13
+
14
+ **Every call blocks**, some of them for as long as programming takes. They
15
+ are meant to be run on a worker thread, one at a time for a given probe:
16
+ nothing here is safe to call from two threads at once, because a probe is
17
+ not.
18
+
19
+ Nothing here imports Qt or a flashing library.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from abc import ABC, abstractmethod
25
+ from collections.abc import Callable, Sequence
26
+ from dataclasses import dataclass
27
+
28
+ from pyflashgui.core.images import Segment
29
+ from pyflashgui.core.memory_map import Region
30
+
31
+ #: Called with how far along something is, from 0.0 to 1.0.
32
+ Progress = Callable[[float], None]
33
+ #: Asked between steps whether to stop. True means somebody pressed Cancel.
34
+ Cancelled = Callable[[], bool]
35
+
36
+ #: Erase only the sectors about to be written, leaving the rest of the flash.
37
+ ERASE_SECTORS = "sectors"
38
+ #: Erase all of the flash first, so nothing of what was there is left.
39
+ ERASE_CHIP = "chip"
40
+
41
+ #: What verifying reads at a time. Small enough that progress moves and a
42
+ #: cancel is noticed; large enough that the probe is not asked a thousand
43
+ #: times for one sector.
44
+ VERIFY_CHUNK = 4096
45
+ #: What reading back asks for at a time, for the same two reasons.
46
+ READ_CHUNK = 0x4000
47
+
48
+
49
+ class BackendError(Exception):
50
+ """Something went wrong that the user can be told about in its own words.
51
+
52
+ Raised with a whole sentence, because the message is what is shown: a
53
+ library's own exception is caught where it is raised and said again here
54
+ in terms of what was being attempted.
55
+ """
56
+
57
+
58
+ class CancelledError(BackendError):
59
+ """Stopped because it was asked to, and not because anything failed."""
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class ProbeInfo:
64
+ """One debug probe that is plugged in."""
65
+
66
+ #: What tells this probe from another of the same kind: its serial number.
67
+ unique_id: str
68
+ description: str
69
+ vendor: str = ""
70
+ product: str = ""
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class TargetInfo:
75
+ """One kind of device a backend knows how to program."""
76
+
77
+ #: What the backend calls it, and what ``connect`` is given.
78
+ name: str
79
+ vendor: str = ""
80
+ #: Where the backend's knowledge of it comes from: "built in", or the
81
+ #: pack it was installed with.
82
+ source: str = ""
83
+ #: The processor in it, where the backend knows: "Cortex-M7".
84
+ core: str = ""
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class Verification:
89
+ """What comparing a target's memory with the files found."""
90
+
91
+ checked: int # bytes compared
92
+ different: int # bytes that were not what the file has
93
+ #: The first byte that differed -- its address, what the file has there
94
+ #: and what the target has -- or None when nothing did.
95
+ first: tuple[int, int, int] | None = None
96
+
97
+ @property
98
+ def ok(self) -> bool:
99
+ return self.different == 0
100
+
101
+
102
+ def _nothing(_fraction: float) -> None:
103
+ """Progress nobody is listening to."""
104
+
105
+
106
+ def _never() -> bool:
107
+ return False
108
+
109
+
110
+ class Connection(ABC):
111
+ """One probe, attached to one target. Closed when finished with.
112
+
113
+ Usable in a ``with`` statement, which closes it however the block ends:
114
+ a probe left attached is one nothing else can use.
115
+ """
116
+
117
+ @abstractmethod
118
+ def regions(self) -> list[Region]:
119
+ """The target's memory, as it turned out to be once connected."""
120
+
121
+ @abstractmethod
122
+ def erase_chip(self) -> None:
123
+ """Erase all of the target's flash."""
124
+
125
+ @abstractmethod
126
+ def erase_ranges(self, ranges: Sequence[tuple[int, int]]) -> None:
127
+ """Erase these stretches of flash, each an address and a length.
128
+
129
+ Flash is erased a sector at a time, so every sector that any of
130
+ a range falls in is erased whole.
131
+ """
132
+
133
+ @abstractmethod
134
+ def program(
135
+ self,
136
+ segments: Sequence[Segment],
137
+ erase: str = ERASE_SECTORS,
138
+ progress: Progress = _nothing,
139
+ ) -> None:
140
+ """Erase what has to be erased and write these bytes.
141
+
142
+ Not cancellable. Stopping half way through leaves a target that is
143
+ neither what it was nor what it was going to be, which is worse than
144
+ either; a board can always be programmed again afterwards.
145
+ """
146
+
147
+ @abstractmethod
148
+ def read(self, address: int, length: int) -> bytes:
149
+ """This many bytes of the target's memory, from this address."""
150
+
151
+ @abstractmethod
152
+ def reset(self, halt: bool = False) -> None:
153
+ """Restart the target, and leave it running unless ``halt``."""
154
+
155
+ @abstractmethod
156
+ def close(self) -> None:
157
+ """Let go of the probe. Safe to call twice."""
158
+
159
+ def read_range(
160
+ self,
161
+ address: int,
162
+ length: int,
163
+ progress: Progress = _nothing,
164
+ cancelled: Cancelled = _never,
165
+ skip_setup: bool = False,
166
+ ) -> bytes:
167
+ """A long run of the target's memory, a piece at a time.
168
+
169
+ ``skip_setup`` asks a backend that sets a device up on connecting
170
+ to it -- which on some parts writes to its RAM -- to leave that
171
+ out for this read. It means something only where the backend's
172
+ ``can_skip_setup`` says so, and is passed over elsewhere.
173
+
174
+ In pieces so that there is progress to report and a cancel is
175
+ noticed: a megabyte asked for in one go is a window that says
176
+ nothing for as long as it takes.
177
+ """
178
+ out = bytearray()
179
+ while len(out) < length:
180
+ if cancelled():
181
+ raise CancelledError("Reading was cancelled.")
182
+ out += self.read(address + len(out), min(READ_CHUNK, length - len(out)))
183
+ progress(len(out) / length)
184
+ return bytes(out)
185
+
186
+ def verify(
187
+ self,
188
+ segments: Sequence[Segment],
189
+ progress: Progress = _nothing,
190
+ cancelled: Cancelled = _never,
191
+ ) -> Verification:
192
+ """Read back what these bytes should have become, and compare.
193
+
194
+ Read through ``read`` rather than left to a library's own check, so
195
+ that it means the same thing with every backend: the bytes that are
196
+ in the target now are the bytes that are in the file.
197
+ """
198
+ total = sum(len(s) for s in segments)
199
+ checked = different = 0
200
+ first: tuple[int, int, int] | None = None
201
+ for segment in segments:
202
+ for offset in range(0, len(segment), VERIFY_CHUNK):
203
+ if cancelled():
204
+ raise CancelledError("Verifying was cancelled.")
205
+ wanted = segment.data[offset : offset + VERIFY_CHUNK]
206
+ found = self.read(segment.address + offset, len(wanted))
207
+ if found != wanted:
208
+ for index, (want, have) in enumerate(zip(wanted, found, strict=True)):
209
+ if want != have:
210
+ different += 1
211
+ if first is None:
212
+ first = (segment.address + offset + index, want, have)
213
+ checked += len(wanted)
214
+ progress(checked / total)
215
+ return Verification(checked=checked, different=different, first=first)
216
+
217
+ def __enter__(self) -> Connection:
218
+ return self
219
+
220
+ def __exit__(self, *_exc) -> None:
221
+ self.close()
222
+
223
+
224
+ class Backend(ABC):
225
+ """One way of reaching probes and targets."""
226
+
227
+ #: What the backend is called in settings, which must not change.
228
+ name: str = ""
229
+ #: What it is called on screen.
230
+ label: str = ""
231
+ #: The debug interfaces that can be asked for, the first being the
232
+ #: one used when none is. Empty where the backend chooses for itself.
233
+ interfaces: tuple[str, ...] = ()
234
+ #: Whether ``regions`` is every address the target can be written at.
235
+ #: Where it is not, a file outside them is worth a warning and is
236
+ #: not refused: the backend may know of memory that it does not list.
237
+ map_is_complete: bool = True
238
+ #: Whether a read can be made without the backend first setting the
239
+ #: device up in its own way. See ``Connection.read_range``.
240
+ can_skip_setup: bool = False
241
+
242
+ @abstractmethod
243
+ def probes(self) -> list[ProbeInfo]:
244
+ """The probes plugged in now. Empty when there are none: never waits."""
245
+
246
+ @abstractmethod
247
+ def targets(self) -> list[TargetInfo]:
248
+ """Every target this backend can be asked for, by name."""
249
+
250
+ @abstractmethod
251
+ def regions(self, target: str) -> list[Region]:
252
+ """A target's memory as far as is known without connecting to one.
253
+
254
+ Empty when it is not known until then, which is not an error: the
255
+ memory map says it could not check, and ``Connection.regions`` has
256
+ the answer once there is a connection.
257
+ """
258
+
259
+ @abstractmethod
260
+ def connect(
261
+ self,
262
+ probe: str,
263
+ target: str,
264
+ frequency: int | None = None,
265
+ interface: str | None = None,
266
+ ) -> Connection:
267
+ """Attach the probe with this unique id to a target of this name.
268
+
269
+ ``frequency`` is the debug clock in hertz, or None for the probe's
270
+ own choice. ``interface`` is one of ``interfaces``, or None for
271
+ the first of them.
272
+ """