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,271 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 davhodg
|
|
3
|
+
"""Probes and targets through pyOCD.
|
|
4
|
+
|
|
5
|
+
Kept thin on purpose: each method is the pyOCD call that does it, with the
|
|
6
|
+
exception said again in pyflashgui's words. Anything worth testing without a
|
|
7
|
+
probe plugged in is in ``core`` or in the base class, and what is left here
|
|
8
|
+
can only be tried on hardware.
|
|
9
|
+
|
|
10
|
+
pyOCD is imported inside the methods that use it. It is the slowest thing
|
|
11
|
+
pyflashgui loads, and a window should not wait for it to say which probes
|
|
12
|
+
are plugged in before it can be drawn.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
|
|
19
|
+
from pyflashgui.backends.base import (
|
|
20
|
+
ERASE_CHIP,
|
|
21
|
+
ERASE_SECTORS,
|
|
22
|
+
Backend,
|
|
23
|
+
BackendError,
|
|
24
|
+
Connection,
|
|
25
|
+
ProbeInfo,
|
|
26
|
+
Progress,
|
|
27
|
+
TargetInfo,
|
|
28
|
+
_nothing,
|
|
29
|
+
)
|
|
30
|
+
from pyflashgui.core.images import Segment
|
|
31
|
+
from pyflashgui.core.memory_map import FLASH, OTHER, RAM, ROM, Region
|
|
32
|
+
|
|
33
|
+
#: What pyOCD's loader calls the two ways of erasing.
|
|
34
|
+
_CHIP_ERASE = {ERASE_SECTORS: "sector", ERASE_CHIP: "chip"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _kind(region) -> str:
|
|
38
|
+
"""One of pyflashgui's kinds of memory, for one of pyOCD's regions."""
|
|
39
|
+
if region.is_flash:
|
|
40
|
+
return FLASH
|
|
41
|
+
if region.is_ram:
|
|
42
|
+
return RAM
|
|
43
|
+
if region.is_rom:
|
|
44
|
+
return ROM
|
|
45
|
+
return OTHER
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def regions_of(memory_map) -> list[Region]:
|
|
49
|
+
"""A pyOCD memory map as pyflashgui's own regions, lowest address first."""
|
|
50
|
+
out = []
|
|
51
|
+
for region in memory_map:
|
|
52
|
+
alias = region.alias
|
|
53
|
+
out.append(
|
|
54
|
+
Region(
|
|
55
|
+
name=region.name,
|
|
56
|
+
start=region.start,
|
|
57
|
+
length=region.length,
|
|
58
|
+
kind=_kind(region),
|
|
59
|
+
# pyOCD's "blocksize" is what is erased at a time, for flash.
|
|
60
|
+
sector_size=int(getattr(region, "blocksize", 0) or 0) if region.is_flash else 0,
|
|
61
|
+
page_size=int(getattr(region, "page_size", 0) or 0) if region.is_flash else 0,
|
|
62
|
+
# An alias is given as the region itself, or as its name.
|
|
63
|
+
alias_of=getattr(alias, "name", alias) or "",
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
return sorted(out, key=lambda r: r.start)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def needs_one_packet_at_a_time(*names: str | None) -> bool:
|
|
70
|
+
"""Whether a probe called this must be sent one command and then waited for.
|
|
71
|
+
|
|
72
|
+
pyOCD's own advice for the TI XDS110, whose firmware loses track when
|
|
73
|
+
several commands are outstanding. Set for every one of them, since it
|
|
74
|
+
is not known which versions are free of it, and the cost is only speed.
|
|
75
|
+
"""
|
|
76
|
+
return any("xds110" in (name or "").lower() for name in names)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _said(doing: str, exc: Exception) -> BackendError:
|
|
80
|
+
"""A library's exception as a sentence about what was being attempted."""
|
|
81
|
+
detail = str(exc).strip() or type(exc).__name__
|
|
82
|
+
return BackendError(f"{doing} failed: {detail}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class PyocdConnection(Connection):
|
|
86
|
+
def __init__(self, session) -> None:
|
|
87
|
+
self._session = session
|
|
88
|
+
|
|
89
|
+
def _target(self):
|
|
90
|
+
if self._session is None:
|
|
91
|
+
raise BackendError("This connection has been closed.")
|
|
92
|
+
return self._session.target
|
|
93
|
+
|
|
94
|
+
def regions(self) -> list[Region]:
|
|
95
|
+
return regions_of(self._target().memory_map)
|
|
96
|
+
|
|
97
|
+
def erase_chip(self) -> None:
|
|
98
|
+
from pyocd.flash.eraser import FlashEraser
|
|
99
|
+
|
|
100
|
+
self._target()
|
|
101
|
+
try:
|
|
102
|
+
FlashEraser(self._session, FlashEraser.Mode.CHIP).erase()
|
|
103
|
+
except Exception as exc:
|
|
104
|
+
raise _said("Erasing the flash", exc) from exc
|
|
105
|
+
|
|
106
|
+
def erase_ranges(self, ranges: Sequence[tuple[int, int]]) -> None:
|
|
107
|
+
from pyocd.flash.eraser import FlashEraser
|
|
108
|
+
|
|
109
|
+
flash = [r for r in self.regions() if r.kind == FLASH and not r.alias_of]
|
|
110
|
+
for start, length in ranges:
|
|
111
|
+
# Checked here: asked to erase what is not flash, pyOCD stops
|
|
112
|
+
# with a line in its own log and no error, having erased
|
|
113
|
+
# nothing, and that would be reported as done.
|
|
114
|
+
if not any(r.start <= start and start + length <= r.end for r in flash):
|
|
115
|
+
raise BackendError(
|
|
116
|
+
f"{start:08X} to {start + length - 1:08X} is not all within one "
|
|
117
|
+
"region of the target's flash."
|
|
118
|
+
)
|
|
119
|
+
try:
|
|
120
|
+
# pyOCD takes where each range starts and where it stops.
|
|
121
|
+
FlashEraser(self._session, FlashEraser.Mode.SECTOR).erase(
|
|
122
|
+
[(start, start + length) for start, length in ranges]
|
|
123
|
+
)
|
|
124
|
+
except Exception as exc:
|
|
125
|
+
raise _said("Erasing the flash", exc) from exc
|
|
126
|
+
|
|
127
|
+
def program(
|
|
128
|
+
self,
|
|
129
|
+
segments: Sequence[Segment],
|
|
130
|
+
erase: str = ERASE_SECTORS,
|
|
131
|
+
progress: Progress = _nothing,
|
|
132
|
+
) -> None:
|
|
133
|
+
from pyocd.flash.loader import MemoryLoader
|
|
134
|
+
|
|
135
|
+
self._target()
|
|
136
|
+
if erase not in _CHIP_ERASE:
|
|
137
|
+
raise BackendError(f"There is no way of erasing called {erase!r}.")
|
|
138
|
+
try:
|
|
139
|
+
# smart_flash off: with it, pyOCD reads the flash first and
|
|
140
|
+
# leaves alone a page it judges unchanged. Every byte in the file
|
|
141
|
+
# is written here, and verifying afterwards says whether it took.
|
|
142
|
+
loader = MemoryLoader(
|
|
143
|
+
self._session,
|
|
144
|
+
progress=progress,
|
|
145
|
+
chip_erase=_CHIP_ERASE[erase],
|
|
146
|
+
smart_flash=False,
|
|
147
|
+
)
|
|
148
|
+
for segment in segments:
|
|
149
|
+
loader.add_data(segment.address, segment.data)
|
|
150
|
+
loader.commit()
|
|
151
|
+
except Exception as exc:
|
|
152
|
+
raise _said("Programming", exc) from exc
|
|
153
|
+
|
|
154
|
+
def read(self, address: int, length: int) -> bytes:
|
|
155
|
+
target = self._target()
|
|
156
|
+
try:
|
|
157
|
+
return bytes(target.read_memory_block8(address, length))
|
|
158
|
+
except Exception as exc:
|
|
159
|
+
raise _said(f"Reading {length} bytes at {address:08X}", exc) from exc
|
|
160
|
+
|
|
161
|
+
def reset(self, halt: bool = False) -> None:
|
|
162
|
+
target = self._target()
|
|
163
|
+
try:
|
|
164
|
+
if halt:
|
|
165
|
+
target.reset_and_halt()
|
|
166
|
+
else:
|
|
167
|
+
target.reset()
|
|
168
|
+
except Exception as exc:
|
|
169
|
+
raise _said("Resetting the target", exc) from exc
|
|
170
|
+
|
|
171
|
+
def close(self) -> None:
|
|
172
|
+
session, self._session = self._session, None
|
|
173
|
+
if session is not None:
|
|
174
|
+
try:
|
|
175
|
+
session.close()
|
|
176
|
+
except Exception as exc:
|
|
177
|
+
raise _said("Disconnecting", exc) from exc
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class PyocdBackend(Backend):
|
|
181
|
+
name = "pyocd"
|
|
182
|
+
label = "pyOCD"
|
|
183
|
+
|
|
184
|
+
def probes(self) -> list[ProbeInfo]:
|
|
185
|
+
from pyocd.core.helpers import ConnectHelper
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
found = ConnectHelper.get_all_connected_probes(blocking=False)
|
|
189
|
+
except Exception as exc:
|
|
190
|
+
raise _said("Looking for probes", exc) from exc
|
|
191
|
+
return [
|
|
192
|
+
ProbeInfo(
|
|
193
|
+
unique_id=probe.unique_id,
|
|
194
|
+
description=probe.description,
|
|
195
|
+
vendor=probe.vendor_name or "",
|
|
196
|
+
product=probe.product_name or "",
|
|
197
|
+
)
|
|
198
|
+
for probe in found
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
def targets(self) -> list[TargetInfo]:
|
|
202
|
+
from pyocd.target import TARGET
|
|
203
|
+
|
|
204
|
+
out = [
|
|
205
|
+
TargetInfo(name, vendor=getattr(cls, "VENDOR", "") or "", source="built in")
|
|
206
|
+
for name, cls in TARGET.items()
|
|
207
|
+
]
|
|
208
|
+
seen = {t.name for t in out}
|
|
209
|
+
for device in self._pack_devices():
|
|
210
|
+
name = device.part_number.lower()
|
|
211
|
+
if name not in seen:
|
|
212
|
+
seen.add(name)
|
|
213
|
+
out.append(TargetInfo(name, vendor=device.vendor or "", source="pack"))
|
|
214
|
+
return sorted(out, key=lambda t: t.name)
|
|
215
|
+
|
|
216
|
+
@staticmethod
|
|
217
|
+
def _pack_devices() -> list:
|
|
218
|
+
"""The devices in the CMSIS-Packs installed here. None is not a fault."""
|
|
219
|
+
try:
|
|
220
|
+
from pyocd.target.pack.pack_target import ManagedPacks
|
|
221
|
+
|
|
222
|
+
return list(ManagedPacks.get_installed_targets())
|
|
223
|
+
except Exception:
|
|
224
|
+
# A pack index that is missing or damaged must not cost the
|
|
225
|
+
# built-in targets, which need no pack at all.
|
|
226
|
+
return []
|
|
227
|
+
|
|
228
|
+
def regions(self, target: str) -> list[Region]:
|
|
229
|
+
from pyocd.target import TARGET
|
|
230
|
+
|
|
231
|
+
cls = TARGET.get(target.lower())
|
|
232
|
+
memory_map = getattr(cls, "MEMORY_MAP", None)
|
|
233
|
+
# A built-in target carries its memory map on the class. A pack's is
|
|
234
|
+
# read from the pack as the target is made, which needs a connection.
|
|
235
|
+
return regions_of(memory_map) if memory_map is not None else []
|
|
236
|
+
|
|
237
|
+
def connect(
|
|
238
|
+
self,
|
|
239
|
+
probe: str,
|
|
240
|
+
target: str,
|
|
241
|
+
frequency: int | None = None,
|
|
242
|
+
interface: str | None = None,
|
|
243
|
+
) -> Connection:
|
|
244
|
+
from pyocd.core.helpers import ConnectHelper
|
|
245
|
+
from pyocd.core.session import Session
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
found = ConnectHelper.get_all_connected_probes(blocking=False, unique_id=probe)
|
|
249
|
+
except Exception as exc:
|
|
250
|
+
raise _said("Looking for the probe", exc) from exc
|
|
251
|
+
# Asked for by the whole id. pyOCD matches any id that *contains*
|
|
252
|
+
# what it is given, and given two would ask on a console which was
|
|
253
|
+
# meant -- a console pyflashgui does not have.
|
|
254
|
+
chosen = next((p for p in found if p.unique_id == probe), None)
|
|
255
|
+
if chosen is None:
|
|
256
|
+
raise BackendError(f"The probe {probe} is not plugged in.")
|
|
257
|
+
options: dict = {
|
|
258
|
+
"target_override": target,
|
|
259
|
+
# pyflashgui reports progress itself, through the callback.
|
|
260
|
+
"hide_programming_progress": True,
|
|
261
|
+
}
|
|
262
|
+
if frequency is not None:
|
|
263
|
+
options["frequency"] = int(frequency)
|
|
264
|
+
if needs_one_packet_at_a_time(chosen.description, chosen.product_name):
|
|
265
|
+
options["cmsis_dap.limit_packets"] = True
|
|
266
|
+
try:
|
|
267
|
+
session = Session(chosen, auto_open=False, options=options)
|
|
268
|
+
session.open()
|
|
269
|
+
except Exception as exc:
|
|
270
|
+
raise _said(f"Connecting to {target}", exc) from exc
|
|
271
|
+
return PyocdConnection(session)
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 davhodg
|
|
3
|
+
"""A probe and a target that exist only in memory.
|
|
4
|
+
|
|
5
|
+
For the tests, which cannot have a board plugged in, and for working on
|
|
6
|
+
pyflashgui at a desk with no hardware on it. It behaves as flash does where
|
|
7
|
+
that matters to what is built on it: erased flash reads as FF, a sector is
|
|
8
|
+
erased whole, and bytes can only be put where the target has memory.
|
|
9
|
+
|
|
10
|
+
What is programmed stays for as long as the backend object does, so that a
|
|
11
|
+
second connection to the same probe finds what the first one wrote -- as a
|
|
12
|
+
board keeps its flash between one connection and the next.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
|
|
19
|
+
from pyflashgui.backends.base import (
|
|
20
|
+
ERASE_CHIP,
|
|
21
|
+
ERASE_SECTORS,
|
|
22
|
+
Backend,
|
|
23
|
+
BackendError,
|
|
24
|
+
Connection,
|
|
25
|
+
ProbeInfo,
|
|
26
|
+
Progress,
|
|
27
|
+
TargetInfo,
|
|
28
|
+
_nothing,
|
|
29
|
+
)
|
|
30
|
+
from pyflashgui.core.images import Segment
|
|
31
|
+
from pyflashgui.core.memory_map import FLASH, RAM, Region, region_at
|
|
32
|
+
|
|
33
|
+
ERASED = 0xFF
|
|
34
|
+
|
|
35
|
+
PROBES = (
|
|
36
|
+
ProbeInfo("SIM0001", "Simulated probe 1", vendor="pyflashgui", product="Simulated probe"),
|
|
37
|
+
ProbeInfo("SIM0002", "Simulated probe 2", vendor="pyflashgui", product="Simulated probe"),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
#: The targets, and the memory of each. Sizes chosen to be awkward in the
|
|
41
|
+
#: ways real parts are: one with a single flash, one with two that do not
|
|
42
|
+
#: touch and have different sector sizes.
|
|
43
|
+
TARGETS: dict[str, tuple[Region, ...]] = {
|
|
44
|
+
"sim_single": (
|
|
45
|
+
Region("flash", 0x0800_0000, 0x4_0000, FLASH, sector_size=0x1000, page_size=0x100),
|
|
46
|
+
Region("ram", 0x2000_0000, 0x1_0000, RAM),
|
|
47
|
+
),
|
|
48
|
+
"sim_dual": (
|
|
49
|
+
Region("boot", 0x0000_0000, 0x8000, FLASH, sector_size=0x400, page_size=0x100),
|
|
50
|
+
Region("application", 0x1000_0000, 0x10_0000, FLASH, sector_size=0x1000, page_size=0x100),
|
|
51
|
+
Region(
|
|
52
|
+
"application_mirror",
|
|
53
|
+
0x1100_0000,
|
|
54
|
+
0x10_0000,
|
|
55
|
+
"rom",
|
|
56
|
+
alias_of="application",
|
|
57
|
+
),
|
|
58
|
+
Region("ram", 0x2000_0000, 0x2_0000, RAM),
|
|
59
|
+
),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _Board:
|
|
64
|
+
"""What is on the end of one simulated probe: a target and its memory."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, target: str) -> None:
|
|
67
|
+
self.target = target
|
|
68
|
+
self.regions = TARGETS[target]
|
|
69
|
+
self.memory = {
|
|
70
|
+
r.name: bytearray([ERASED if r.kind == FLASH else 0]) * r.length
|
|
71
|
+
for r in self.regions
|
|
72
|
+
if not r.alias_of
|
|
73
|
+
}
|
|
74
|
+
self.resets = 0
|
|
75
|
+
|
|
76
|
+
def storage(self, region: Region) -> bytearray:
|
|
77
|
+
"""The bytes behind a region, which for an alias are another's."""
|
|
78
|
+
return self.memory[region.alias_of or region.name]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class SimulatedConnection(Connection):
|
|
82
|
+
def __init__(self, backend: SimulatedBackend, probe: str, board: _Board) -> None:
|
|
83
|
+
self._backend = backend
|
|
84
|
+
self._probe = probe
|
|
85
|
+
self._board: _Board | None = board
|
|
86
|
+
|
|
87
|
+
def _open(self) -> _Board:
|
|
88
|
+
if self._board is None:
|
|
89
|
+
raise BackendError("This connection has been closed.")
|
|
90
|
+
return self._board
|
|
91
|
+
|
|
92
|
+
def regions(self) -> list[Region]:
|
|
93
|
+
return list(self._open().regions)
|
|
94
|
+
|
|
95
|
+
def erase_chip(self) -> None:
|
|
96
|
+
board = self._open()
|
|
97
|
+
for region in board.regions:
|
|
98
|
+
if region.kind == FLASH and not region.alias_of:
|
|
99
|
+
board.memory[region.name][:] = bytes([ERASED]) * region.length
|
|
100
|
+
|
|
101
|
+
def erase_ranges(self, ranges: Sequence[tuple[int, int]]) -> None:
|
|
102
|
+
board = self._open()
|
|
103
|
+
# Everything is placed before anything is erased, so that a range
|
|
104
|
+
# that is not flash leaves the target as it was.
|
|
105
|
+
placed = [self._place(board, start, length) for start, length in ranges]
|
|
106
|
+
for runs in placed:
|
|
107
|
+
for region, _offset, _length in runs:
|
|
108
|
+
if region.kind != FLASH:
|
|
109
|
+
raise BackendError(f"{region.name} is not flash, and cannot be erased.")
|
|
110
|
+
for runs in placed:
|
|
111
|
+
for region, offset, length in runs:
|
|
112
|
+
size = region.sector_size
|
|
113
|
+
start = offset // size * size
|
|
114
|
+
stop = -(-(offset + length) // size) * size
|
|
115
|
+
board.memory[region.name][start:stop] = bytes([ERASED]) * (stop - start)
|
|
116
|
+
|
|
117
|
+
def _place(self, board: _Board, address: int, length: int) -> list[tuple[Region, int, int]]:
|
|
118
|
+
"""A run of bytes as (region, offset in it, length), region by region."""
|
|
119
|
+
out = []
|
|
120
|
+
end = address + length
|
|
121
|
+
while address < end:
|
|
122
|
+
region = region_at(board.regions, address)
|
|
123
|
+
if region is None:
|
|
124
|
+
raise BackendError(f"The target has no memory at {address:08X}.")
|
|
125
|
+
if region.alias_of or region.kind not in (FLASH, RAM):
|
|
126
|
+
raise BackendError(f"{address:08X} is in {region.name}, which cannot be written.")
|
|
127
|
+
stop = min(end, region.end)
|
|
128
|
+
out.append((region, address - region.start, stop - address))
|
|
129
|
+
address = stop
|
|
130
|
+
return out
|
|
131
|
+
|
|
132
|
+
def program(
|
|
133
|
+
self,
|
|
134
|
+
segments: Sequence[Segment],
|
|
135
|
+
erase: str = ERASE_SECTORS,
|
|
136
|
+
progress: Progress = _nothing,
|
|
137
|
+
) -> None:
|
|
138
|
+
board = self._open()
|
|
139
|
+
if erase not in (ERASE_SECTORS, ERASE_CHIP):
|
|
140
|
+
raise BackendError(f"There is no way of erasing called {erase!r}.")
|
|
141
|
+
# Everything is placed before anything is changed, so that a file
|
|
142
|
+
# that does not fit leaves the target as it was.
|
|
143
|
+
placed = [(s, self._place(board, s.address, len(s))) for s in segments]
|
|
144
|
+
if erase == ERASE_CHIP:
|
|
145
|
+
self.erase_chip()
|
|
146
|
+
else:
|
|
147
|
+
for _segment, runs in placed:
|
|
148
|
+
for region, offset, length in runs:
|
|
149
|
+
if region.kind != FLASH:
|
|
150
|
+
continue
|
|
151
|
+
size = region.sector_size
|
|
152
|
+
start = offset // size * size
|
|
153
|
+
stop = -(-(offset + length) // size) * size
|
|
154
|
+
board.memory[region.name][start:stop] = bytes([ERASED]) * (stop - start)
|
|
155
|
+
total = sum(len(s) for s in segments)
|
|
156
|
+
done = 0
|
|
157
|
+
for segment, runs in placed:
|
|
158
|
+
position = 0
|
|
159
|
+
for region, offset, length in runs:
|
|
160
|
+
data = segment.data[position : position + length]
|
|
161
|
+
board.memory[region.name][offset : offset + length] = data
|
|
162
|
+
position += length
|
|
163
|
+
done += length
|
|
164
|
+
progress(done / total)
|
|
165
|
+
|
|
166
|
+
def read(self, address: int, length: int) -> bytes:
|
|
167
|
+
board = self._open()
|
|
168
|
+
out = bytearray()
|
|
169
|
+
end = address + length
|
|
170
|
+
while address < end:
|
|
171
|
+
region = region_at(board.regions, address)
|
|
172
|
+
if region is None:
|
|
173
|
+
raise BackendError(f"The target has no memory at {address:08X}.")
|
|
174
|
+
stop = min(end, region.end)
|
|
175
|
+
offset = address - region.start
|
|
176
|
+
out += board.storage(region)[offset : offset + stop - address]
|
|
177
|
+
address = stop
|
|
178
|
+
return bytes(out)
|
|
179
|
+
|
|
180
|
+
def reset(self, halt: bool = False) -> None:
|
|
181
|
+
board = self._open()
|
|
182
|
+
board.resets += 1
|
|
183
|
+
# What a reset does to RAM on a real part is nothing anybody should
|
|
184
|
+
# rely on. Cleared here, so that relying on it shows up.
|
|
185
|
+
for region in board.regions:
|
|
186
|
+
if region.kind == RAM:
|
|
187
|
+
board.memory[region.name][:] = bytes(region.length)
|
|
188
|
+
|
|
189
|
+
def close(self) -> None:
|
|
190
|
+
if self._board is not None:
|
|
191
|
+
self._board = None
|
|
192
|
+
self._backend._released(self._probe)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class SimulatedBackend(Backend):
|
|
196
|
+
name = "simulated"
|
|
197
|
+
label = "Simulated"
|
|
198
|
+
|
|
199
|
+
def __init__(self) -> None:
|
|
200
|
+
#: Probe id -> what it is attached to. Made when first connected to,
|
|
201
|
+
#: and kept, so that flash outlives a connection.
|
|
202
|
+
self._boards: dict[str, _Board] = {}
|
|
203
|
+
self._in_use: set[str] = set()
|
|
204
|
+
|
|
205
|
+
def probes(self) -> list[ProbeInfo]:
|
|
206
|
+
return list(PROBES)
|
|
207
|
+
|
|
208
|
+
def targets(self) -> list[TargetInfo]:
|
|
209
|
+
return [TargetInfo(name, vendor="pyflashgui", source="built in") for name in TARGETS]
|
|
210
|
+
|
|
211
|
+
def regions(self, target: str) -> list[Region]:
|
|
212
|
+
return list(TARGETS.get(target, ()))
|
|
213
|
+
|
|
214
|
+
def connect(
|
|
215
|
+
self,
|
|
216
|
+
probe: str,
|
|
217
|
+
target: str,
|
|
218
|
+
frequency: int | None = None,
|
|
219
|
+
interface: str | None = None,
|
|
220
|
+
) -> Connection:
|
|
221
|
+
if probe not in {p.unique_id for p in PROBES}:
|
|
222
|
+
raise BackendError(f"There is no probe with the id {probe}.")
|
|
223
|
+
if target not in TARGETS:
|
|
224
|
+
raise BackendError(f"{target} is not a target the simulated backend has.")
|
|
225
|
+
if probe in self._in_use:
|
|
226
|
+
raise BackendError(f"Probe {probe} is already connected.")
|
|
227
|
+
board = self._boards.get(probe)
|
|
228
|
+
if board is None or board.target != target:
|
|
229
|
+
# A different target on the same probe is a different board
|
|
230
|
+
# plugged in, with nothing of the last one's on it.
|
|
231
|
+
board = self._boards[probe] = _Board(target)
|
|
232
|
+
self._in_use.add(probe)
|
|
233
|
+
return SimulatedConnection(self, probe, board)
|
|
234
|
+
|
|
235
|
+
def _released(self, probe: str) -> None:
|
|
236
|
+
self._in_use.discard(probe)
|
|
237
|
+
|
|
238
|
+
def board(self, probe: str) -> _Board | None:
|
|
239
|
+
"""What is on the end of a probe, for a test to look at or tamper with."""
|
|
240
|
+
return self._boards.get(probe)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 davhodg
|
|
3
|
+
"""Which commit a source checkout is running, for a report to name.
|
|
4
|
+
|
|
5
|
+
The version number is the same for every build between two releases, which is
|
|
6
|
+
no use when somebody is running what they pulled this morning: "it does X" and
|
|
7
|
+
"it did X yesterday" are the same sentence about two different programs. A
|
|
8
|
+
checkout knows exactly what it is, and the answer is in ``.git`` for the
|
|
9
|
+
reading.
|
|
10
|
+
|
|
11
|
+
Read from the files rather than by running ``git``: the launcher starts
|
|
12
|
+
pythonw, so a subprocess would flash a console window on Windows, it would be
|
|
13
|
+
a process start on the way to the first window, and a frozen build has no git
|
|
14
|
+
to run. Reading two small files costs nothing and works the same everywhere.
|
|
15
|
+
|
|
16
|
+
Nothing here reports whether the working tree has been edited. That needs the
|
|
17
|
+
index compared against the files, which is what git is for; the commit is what
|
|
18
|
+
somebody needs to tell one build from another.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
#: Enough of a hash to be unambiguous in any repository this size, and short
|
|
26
|
+
#: enough to be read out over a telephone.
|
|
27
|
+
SHORT = 7
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _git_dir(root: Path) -> Path | None:
|
|
31
|
+
"""The .git directory, which for a worktree is a file pointing at one."""
|
|
32
|
+
git = root / ".git"
|
|
33
|
+
if git.is_dir():
|
|
34
|
+
return git
|
|
35
|
+
if git.is_file():
|
|
36
|
+
# A linked worktree: ".git" holds "gitdir: <path>".
|
|
37
|
+
try:
|
|
38
|
+
said = git.read_text(encoding="utf-8").strip()
|
|
39
|
+
except OSError:
|
|
40
|
+
return None
|
|
41
|
+
if said.startswith("gitdir:"):
|
|
42
|
+
where = Path(said.split(":", 1)[1].strip())
|
|
43
|
+
if not where.is_absolute():
|
|
44
|
+
where = (root / where).resolve()
|
|
45
|
+
return where if where.is_dir() else None
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _packed(git: Path, ref: str) -> str | None:
|
|
50
|
+
"""A ref that has been packed away, which is where an old branch ends up."""
|
|
51
|
+
try:
|
|
52
|
+
lines = (git / "packed-refs").read_text(encoding="utf-8").splitlines()
|
|
53
|
+
except OSError:
|
|
54
|
+
return None
|
|
55
|
+
for line in lines:
|
|
56
|
+
if line.startswith("#") or line.startswith("^"):
|
|
57
|
+
continue
|
|
58
|
+
parts = line.split()
|
|
59
|
+
if len(parts) == 2 and parts[1] == ref:
|
|
60
|
+
return parts[0]
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _resolve(git: Path, ref: str) -> str | None:
|
|
65
|
+
"""A ref's commit: the loose file first, because packed can be stale."""
|
|
66
|
+
loose = git / ref
|
|
67
|
+
try:
|
|
68
|
+
if loose.is_file():
|
|
69
|
+
return loose.read_text(encoding="utf-8").strip() or None
|
|
70
|
+
except OSError:
|
|
71
|
+
return None
|
|
72
|
+
return _packed(git, ref)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _tag_for(git: Path, commit: str) -> str | None:
|
|
76
|
+
"""A tag pointing at this commit, if one does: that is the better name."""
|
|
77
|
+
tags = git / "refs" / "tags"
|
|
78
|
+
if tags.is_dir():
|
|
79
|
+
for path in sorted(tags.rglob("*")):
|
|
80
|
+
try:
|
|
81
|
+
if path.is_file() and path.read_text(encoding="utf-8").strip() == commit:
|
|
82
|
+
return path.name
|
|
83
|
+
except OSError:
|
|
84
|
+
continue
|
|
85
|
+
try:
|
|
86
|
+
lines = (git / "packed-refs").read_text(encoding="utf-8").splitlines()
|
|
87
|
+
except OSError:
|
|
88
|
+
return None
|
|
89
|
+
for line in lines:
|
|
90
|
+
parts = line.split()
|
|
91
|
+
if len(parts) == 2 and parts[1].startswith("refs/tags/") and parts[0] == commit:
|
|
92
|
+
return parts[1][len("refs/tags/") :]
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def head(root: Path | None = None) -> tuple[str | None, str] | None:
|
|
97
|
+
"""(branch, full commit) this checkout is on, or None outside a checkout.
|
|
98
|
+
|
|
99
|
+
The branch is None when the checkout is on a commit rather than
|
|
100
|
+
following a branch -- a tag, or an old commit checked out.
|
|
101
|
+
"""
|
|
102
|
+
root = Path(__file__).resolve().parents[2] if root is None else Path(root)
|
|
103
|
+
git = _git_dir(root)
|
|
104
|
+
if git is None:
|
|
105
|
+
return None
|
|
106
|
+
try:
|
|
107
|
+
said = (git / "HEAD").read_text(encoding="utf-8").strip()
|
|
108
|
+
except OSError:
|
|
109
|
+
return None
|
|
110
|
+
if said.startswith("ref:"):
|
|
111
|
+
ref = said.split(":", 1)[1].strip()
|
|
112
|
+
commit = _resolve(git, ref)
|
|
113
|
+
branch = ref.removeprefix("refs/heads/")
|
|
114
|
+
else:
|
|
115
|
+
commit, branch = said, None
|
|
116
|
+
return (branch, commit) if commit else None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def describe(root: Path | None = None) -> str | None:
|
|
120
|
+
"""The branch or tag and the short hash, or None outside a checkout.
|
|
121
|
+
|
|
122
|
+
None is the ordinary answer for an installed or frozen build, which has
|
|
123
|
+
no repository to ask and a version number that means something anyway.
|
|
124
|
+
"""
|
|
125
|
+
root = Path(__file__).resolve().parents[2] if root is None else Path(root)
|
|
126
|
+
if (found := head(root)) is None:
|
|
127
|
+
return None
|
|
128
|
+
branch, commit = found
|
|
129
|
+
git = _git_dir(root)
|
|
130
|
+
tag = _tag_for(git, commit) if git is not None else None
|
|
131
|
+
# Detached: sitting on a commit rather than following a branch, which is
|
|
132
|
+
# what checking out a tag or an old commit leaves.
|
|
133
|
+
name = tag or (branch.rsplit("/", 1)[-1] if branch else "detached")
|
|
134
|
+
return f"{name} {commit[:SHORT]}"
|