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,157 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 davhodg
3
+ """What was read from a target against what a file says should be there.
4
+
5
+ The same question Verify asks, the other way about: Verify reads what the
6
+ files cover and says whether it matches, and this takes what has already been
7
+ read -- any stretch of memory, from any time -- and says where a file agrees
8
+ with it and where it does not.
9
+
10
+ A file and a read rarely cover the same addresses. Only where they overlap is
11
+ there anything to compare, and what each has that the other does not is
12
+ counted and said rather than called a difference: flash the file does not
13
+ mention is not flash the file says is wrong.
14
+
15
+ Nothing here touches a probe or Qt.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from collections.abc import Sequence
21
+ from dataclasses import dataclass
22
+
23
+ from pyflashgui.core.images import Image, Segment
24
+
25
+ SAME, DIFFERENT = 0, 1
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Comparison:
30
+ """Where a file and a read agree, byte by byte over the read."""
31
+
32
+ #: The file's name, for saying what was compared with.
33
+ name: str
34
+ #: Where the read starts.
35
+ address: int
36
+ #: A byte for each byte read: DIFFERENT where the file has another value
37
+ #: there, SAME where it has the same or says nothing.
38
+ differs: bytes
39
+ #: A byte for each byte read: 1 where the file has a value for it.
40
+ covered: bytes
41
+ #: The file's value for each byte read, where it has one.
42
+ file: bytes
43
+ #: Bytes the file has at addresses that were not read.
44
+ outside: int
45
+
46
+ @property
47
+ def compared(self) -> int:
48
+ """Bytes that both have, which is all that could be compared."""
49
+ return self.covered.count(1)
50
+
51
+ @property
52
+ def different(self) -> int:
53
+ return self.differs.count(DIFFERENT)
54
+
55
+ @property
56
+ def unmentioned(self) -> int:
57
+ """Bytes read that the file says nothing about."""
58
+ return len(self.covered) - self.compared
59
+
60
+ @property
61
+ def first(self) -> int | None:
62
+ """The offset of the first byte that differs, or None when none does."""
63
+ at = self.differs.find(DIFFERENT)
64
+ return at if at >= 0 else None
65
+
66
+ def run_from(self, offset: int) -> int:
67
+ """How many bytes in a row differ, starting at this one."""
68
+ end = self.differs.find(SAME, offset)
69
+ return (len(self.differs) if end < 0 else end) - offset
70
+
71
+ def next_run(self, after: int | None) -> int | None:
72
+ """Where the next stretch of differing bytes starts, going round at the end.
73
+
74
+ ``after`` is where the last one shown starts, or None for the first
75
+ of all. None back when nothing differs.
76
+ """
77
+ if self.first is None:
78
+ return None
79
+ if after is None:
80
+ return self.first
81
+ end = self.differs.find(SAME, after)
82
+ at = -1 if end < 0 else self.differs.find(DIFFERENT, end)
83
+ return at if at >= 0 else self.first
84
+
85
+ def previous_run(self, before: int | None) -> int | None:
86
+ """Where the stretch of differing bytes before this one starts, going round."""
87
+ if self.first is None:
88
+ return None
89
+ last = self.differs.rfind(DIFFERENT, 0, before) if before else -1
90
+ if last < 0:
91
+ last = self.differs.rfind(DIFFERENT)
92
+ # Back from its last byte to its first: the byte after the one
93
+ # before it that does not differ.
94
+ return self.differs.rfind(SAME, 0, last) + 1
95
+
96
+
97
+ #: Any byte but zero becomes DIFFERENT: what two bytes exclusive-or to is
98
+ #: zero only where they are the same.
99
+ _ANY_BIT = bytes([SAME]) + bytes([DIFFERENT]) * 255
100
+
101
+
102
+ def _unlike(one: bytes, other: bytes) -> bytes:
103
+ """A byte for each pair: DIFFERENT where the two are not the same.
104
+
105
+ As two whole numbers, exclusive-ored: a megabyte compared a byte at a
106
+ time in Python is a second of a window that does not answer.
107
+ """
108
+ mixed = int.from_bytes(one, "big") ^ int.from_bytes(other, "big")
109
+ return mixed.to_bytes(len(one), "big").translate(_ANY_BIT)
110
+
111
+
112
+ def compare(address: int, data: bytes, image: Image) -> Comparison:
113
+ """Lay a file over what was read from ``address``, and mark where they differ."""
114
+ file = bytearray(len(data))
115
+ covered = bytearray(len(data))
116
+ differs = bytearray(len(data))
117
+ outside = 0
118
+ end = address + len(data)
119
+ for segment in image.segments:
120
+ start = max(segment.address, address)
121
+ stop = min(segment.end, end)
122
+ overlap = max(0, stop - start)
123
+ outside += len(segment) - overlap
124
+ if overlap:
125
+ at, within = start - address, start - segment.address
126
+ wanted = segment.data[within : within + overlap]
127
+ file[at : at + overlap] = wanted
128
+ covered[at : at + overlap] = b"\x01" * overlap
129
+ differs[at : at + overlap] = _unlike(wanted, data[at : at + overlap])
130
+ return Comparison(
131
+ name=image.name,
132
+ address=address,
133
+ differs=bytes(differs),
134
+ covered=bytes(covered),
135
+ file=bytes(file),
136
+ outside=outside,
137
+ )
138
+
139
+
140
+ def compare_all(segments: Sequence[Segment], image: Image) -> Comparison:
141
+ """Lay a file over several runs of memory, one after another.
142
+
143
+ For what is shown in pieces, as a file with gaps in it is. The marks
144
+ are for the pieces end to end, in the order given, and ``address`` is
145
+ where the first starts: where a later byte is, is for whoever holds the
146
+ pieces to say.
147
+ """
148
+ made = [compare(segment.address, segment.data, image) for segment in segments]
149
+ covered = b"".join(one.covered for one in made)
150
+ return Comparison(
151
+ name=image.name,
152
+ address=segments[0].address if segments else 0,
153
+ differs=b"".join(one.differs for one in made),
154
+ covered=covered,
155
+ file=b"".join(one.file for one in made),
156
+ outside=image.size - covered.count(1),
157
+ )
@@ -0,0 +1,37 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 davhodg
3
+ """What the panes are handed: where things live, and where to say things.
4
+
5
+ Keep this small and stable: everything a pane needs to remember something or
6
+ to tell the user something should be reachable from ``ctx``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ from pyflashgui.core import paths, timing, workspaces
14
+ from pyflashgui.core.events import EventLog
15
+ from pyflashgui.core.layout import Layout
16
+ from pyflashgui.core.settings import Settings
17
+
18
+
19
+ class Context:
20
+ def __init__(self, events: EventLog | None = None) -> None:
21
+ # Marked in three because the whole of it is folders being made and
22
+ # two files being read, which costs nothing on a local disc and a
23
+ # surprising amount on a profile that is redirected to a network
24
+ # share or watched by a scanner.
25
+ self.user_dir: Path = paths.user_dir()
26
+ timing.mark("user folder")
27
+ #: Which product is being worked on. Everything below belongs to it.
28
+ self.workspace: str = workspaces.active()
29
+ self.workspace_dir: Path = workspaces.active_dir()
30
+ timing.mark("workspace folders")
31
+ self.settings = Settings(workspaces.settings_path())
32
+ #: The dock arrangement, in the workspace folder rather than in
33
+ #: QSettings, so that the workspace is one thing that can be copied.
34
+ self.layout = Layout(workspaces.layout_path())
35
+ timing.mark("settings and layout files")
36
+ #: Everything said to the user goes through here, with a level.
37
+ self.events = events if events is not None else EventLog()
@@ -0,0 +1,71 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 davhodg
3
+ """Everything the application has to say, and how much it matters.
4
+
5
+ Every line that reaches the Event Log comes through here, with a level. That
6
+ one change buys two things.
7
+
8
+ The first is that a failure cannot be lost. The Event Log is a pane like any
9
+ other and can be closed, and closing it must not mean "throw away anything
10
+ you were going to tell me" -- press Program with no probe connected, and the
11
+ button would simply do nothing. So a warning or an error opens the pane; a
12
+ plain note does not, so closing it still means what it should mean, which is
13
+ "stop chattering at me".
14
+
15
+ The second is that the appearance of a line is decided in one place rather
16
+ than at every call site, so how warnings and errors look is a change to the
17
+ sink and nowhere else.
18
+
19
+ Levels are strings rather than an enum on purpose: they are passed about as
20
+ plain text, and nothing has to be imported to name one.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from PySide6.QtCore import QObject, Signal
26
+
27
+ INFORMATION = "information"
28
+ WARNING = "warning"
29
+ ERROR = "error"
30
+ #: Something that was wrong is right again, or something worked: a board
31
+ #: verified, a probe came back. Not an escalation of anything -- it is the one
32
+ #: piece of news a log of failures otherwise never carries, and reading back
33
+ #: through red to find out whether a fault ever cleared is a poor way to
34
+ #: spend a morning.
35
+ GOOD = "good"
36
+
37
+ #: In the order they escalate, with GOOD off to the side. Anything
38
+ #: unrecognised is treated as information: a mistyped level must not be able
39
+ #: to make the pane spring open, nor to silently swallow the message.
40
+ LEVELS = (INFORMATION, GOOD, WARNING, ERROR)
41
+
42
+ #: The levels that mean something needs looking at.
43
+ PROBLEMS = (WARNING, ERROR)
44
+
45
+
46
+ class EventLog(QObject):
47
+ """The one way in. What listens is somebody else's business."""
48
+
49
+ posted = Signal(str, str) # message, level
50
+
51
+ def post(self, message: str, level: str = INFORMATION) -> None:
52
+ # Emitted rather than called: messages arrive from worker threads --
53
+ # a flash in progress, the exception hook -- and a Qt widget may
54
+ # only be touched from the GUI thread. A queued signal is the crossing.
55
+ self.posted.emit(str(message), level if level in LEVELS else INFORMATION)
56
+
57
+ def information(self, message: str) -> None:
58
+ """Something happened. Worth recording, not worth interrupting for."""
59
+ self.post(message, INFORMATION)
60
+
61
+ def warning(self, message: str) -> None:
62
+ """What you asked for did not happen, or did not happen as asked."""
63
+ self.post(message, WARNING)
64
+
65
+ def error(self, message: str) -> None:
66
+ """Something went wrong that nobody asked for."""
67
+ self.post(message, ERROR)
68
+
69
+ def good(self, message: str) -> None:
70
+ """Something that was wrong is right again."""
71
+ self.post(message, GOOD)
@@ -0,0 +1,110 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 davhodg
3
+ """Put unhandled exceptions in the Event Log instead of nowhere.
4
+
5
+ pyflashgui is started with ``pythonw.exe``, which has no console. An exception
6
+ raised inside a Qt slot does not propagate anywhere useful: PySide prints the
7
+ traceback to a standard error stream that does not exist, the slot simply
8
+ does not finish, and the window carries on looking perfectly healthy.
9
+
10
+ A stale call to a widget method -- ``.text()`` on what had become a combo box
11
+ -- is enough: nothing is logged, no dialog appears, and whatever the slot was
12
+ doing is left half done.
13
+
14
+ So the hook routes tracebacks to the Event Log, where somebody can see them.
15
+ Delivery goes through a signal because an exception can be raised on any
16
+ thread and Qt widgets may only be touched from the GUI one.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import sys
22
+ import threading
23
+ import traceback
24
+ from collections.abc import Callable
25
+
26
+ from PySide6.QtCore import QObject, Qt, QtMsgType, Signal, Slot, qInstallMessageHandler
27
+
28
+ from pyflashgui.core.events import ERROR, INFORMATION
29
+
30
+ #: Said of a Python traceback and of nothing else. Qt complains through the
31
+ #: same handler about things that are often not pyflashgui's doing at all -- a
32
+ #: platform plugin, a window manager -- and telling somebody those are bugs in
33
+ #: this application is both wrong and unhelpful.
34
+ BUG = "Unhandled error (this is a bug in pyflashgui; the action it interrupted did not finish):"
35
+
36
+
37
+ class ExceptionLogger(QObject):
38
+ """Sends unhandled exceptions to a sink on the GUI thread."""
39
+
40
+ caught = Signal(str, str) # message, level
41
+
42
+ def __init__(self, sink: Callable[[str, str], None]) -> None:
43
+ super().__init__()
44
+ self._sink = sink
45
+ self._previous = None
46
+ self._previous_thread = None
47
+ self._previous_qt = None
48
+ self._seen: set[str] = set()
49
+ # Queued: emitted from a worker thread, delivered on the GUI thread.
50
+ self.caught.connect(self._deliver, Qt.QueuedConnection)
51
+
52
+ def install(self) -> None:
53
+ self._previous = sys.excepthook
54
+ sys.excepthook = self._hook
55
+ # Worker threads have their own hook, and protocol work happens on
56
+ # them: an SDO read that dies must not die quietly either.
57
+ self._previous_thread = threading.excepthook
58
+ threading.excepthook = self._thread_hook
59
+ # Qt complains through its own channel, and those complaints are
60
+ # often the explanation -- a signal connected to nothing, a widget
61
+ # touched off the GUI thread.
62
+ self._previous_qt = qInstallMessageHandler(self._qt_hook)
63
+
64
+ def remove(self) -> None:
65
+ if self._previous is not None:
66
+ sys.excepthook = self._previous
67
+ self._previous = None
68
+ if self._previous_thread is not None:
69
+ threading.excepthook = self._previous_thread
70
+ self._previous_thread = None
71
+ qInstallMessageHandler(self._previous_qt)
72
+ self._previous_qt = None
73
+
74
+ def _thread_hook(self, args) -> None:
75
+ text = "".join(
76
+ traceback.format_exception(args.exc_type, args.exc_value, args.exc_traceback)
77
+ ).rstrip()
78
+ name = getattr(args.thread, "name", "?")
79
+ self.caught.emit(f"{BUG}\non thread {name}:\n{text}", ERROR)
80
+ if self._previous_thread is not None:
81
+ self._previous_thread(args)
82
+
83
+ def _qt_hook(self, mode, context, message) -> None:
84
+ # Critical and fatal are worth interrupting for. A Qt warning is
85
+ # usually the explanation of something else rather than the something
86
+ # else -- "unable to set geometry", "this plugin does not support
87
+ # propagateSizeHints" -- so it is recorded and left there, or the
88
+ # Event Log would spring open at every start on some machines.
89
+ if mode in (QtMsgType.QtCriticalMsg, QtMsgType.QtFatalMsg):
90
+ self.caught.emit(f"Qt: {message}", ERROR)
91
+ elif mode == QtMsgType.QtWarningMsg:
92
+ self.caught.emit(f"Qt: {message}", INFORMATION)
93
+ if self._previous_qt is not None:
94
+ self._previous_qt(mode, context, message)
95
+
96
+ def _hook(self, exc_type, exc, tb) -> None:
97
+ text = "".join(traceback.format_exception(exc_type, exc, tb)).rstrip()
98
+ self.caught.emit(f"{BUG}\n{text}", ERROR)
99
+ if self._previous is not None:
100
+ self._previous(exc_type, exc, tb) # keep stderr working when there is one
101
+
102
+ @Slot(str, str)
103
+ def _deliver(self, text: str, level: str) -> None:
104
+ # A fault inside a repeating timer would otherwise fill the log with
105
+ # the same traceback fifty times a second.
106
+ key = text.rsplit("\n", 1)[-1]
107
+ if key in self._seen:
108
+ return
109
+ self._seen.add(key)
110
+ self._sink(text, level)