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
pyflashgui/__init__.py
ADDED
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
|
+
"""
|