Skip to content

Commit 7ae5e00

Browse files
authored
Implement Multi device support (#6)
## Summary Refactors the codebase from a single DM40-only app into a device-agnostic framework and adds an experimental EL15 electronic load handler. ## Changes ### Architecture refactor - `shared/base_app.py` — device-independent UI (BLE scanning, connection lifecycle, raw packet log, waveform, stats) extracted into a reusable `App` base class - `shared/ble_worker.py` — BLE transport decoupled from device logic; emits typed callbacks - `shared/device_registry.py` — maps device-type strings to handler classes, BLE name prefixes, and discovery family bytes; local imports inside `load_handler()` break the circular import while remaining statically traceable by Nuitka - `dm40/app.py` — existing logic moved into `DM40Handler`; no functional changes ### EL15 handler (experimental) - 28-byte frame parser covering CC, CV, CR, CP, CAP, DCR, ADV, POW [A/DT/RPT], ADV [S] - Protection fault detection via byte 5 bits 1+2; decodes UVP/REV/generic codes from byte 6 upper nibble - `_last_valid_mode` prevents fault-bit collisions (CAP↔CC, DCR↔CV) from corrupting the mode display - UI: mode bar, voltage waveform with U/I/P tooltip, temperature (3 d.p.), runtime, fan speed, setpoint entry, warning/fault detection ### Other - Waveform tooltip supports a custom value label and extra lines; timestamp always last - Raw packet log: auto-scroll only follows output when already at the bottom (terminal behaviour)
2 parents bd572d6 + b484345 commit 7ae5e00

31 files changed

Lines changed: 1873 additions & 1392 deletions

GUI/controls.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
1-
from dm40.types import ThemePalette
2-
3-
41
class UIControls:
5-
def __init__(self, master, style, theme: ThemePalette):
2+
def __init__(self, master, style, theme):
63
self.master = master
74
self.style = style
85
self.theme = theme
@@ -11,7 +8,7 @@ def __init__(self, master, style, theme: ThemePalette):
118
self._init_layouts()
129
self.apply_theme()
1310

14-
def use_theme(self, theme: ThemePalette) -> None:
11+
def use_theme(self, theme) -> None:
1512
self.theme = theme
1613
self.apply_theme()
1714

GUI/theme_manager.py

Lines changed: 9 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@
22
import tkinter as tk
33
from tkinter import ttk
44

5-
from dm40.types import ThemePalette
6-
from dm40.theme_store import deserialize_theme_store_palettes
7-
from GUI.widgets.helpers import theme_title_bar
5+
from shared.theme_store import deserialize_theme_store_palettes
6+
from GUI.widgets.helpers import center_on_parent, theme_title_bar
87
from GUI.widgets.themed_button import ThemedButton
98

109
_PREVIEW_FRAME = "ThemePreview.TFrame"
@@ -34,7 +33,7 @@ def __init__(
3433
self.style = style
3534
self._on_apply = on_apply
3635

37-
self._themes: list[ThemePalette] = deserialize_theme_store_palettes(_DEFAULT_STORE)
36+
self._themes = deserialize_theme_store_palettes(_DEFAULT_STORE)
3837
self._active_theme_idx = self._read_active_index()
3938

4039
self._dialog = None
@@ -64,7 +63,7 @@ def list_theme_names(self) -> list[str]:
6463
def get_active_theme_index(self) -> int:
6564
return self._active_theme_idx
6665

67-
def get_active_theme(self) -> ThemePalette:
66+
def get_active_theme(self):
6867
return self._themes[self._active_theme_idx]
6968

7069
def activate_theme_index(self, theme_idx: int):
@@ -234,7 +233,7 @@ def _apply_selected_theme(self):
234233
self._select_listbox_index(self._active_theme_idx)
235234
self._update_preview_from_selection()
236235

237-
def _activate_by_index(self, idx: int) -> tuple[ThemePalette | None, bool]:
236+
def _activate_by_index(self, idx: int):
238237
if idx < 0 or idx >= len(self._themes):
239238
return None, False
240239
if self._active_theme_idx == idx:
@@ -246,7 +245,7 @@ def _activate_by_index(self, idx: int) -> tuple[ThemePalette | None, bool]:
246245
self._update_listbox_active(old_idx)
247246
return self._themes[idx], True
248247

249-
def _apply_dialog_chrome(self, theme: ThemePalette):
248+
def _apply_dialog_chrome(self, theme):
250249
dialog = self._dialog
251250
if not dialog or not dialog.winfo_exists():
252251
return
@@ -261,7 +260,7 @@ def _init_preview_styles(self):
261260
self.style.layout(_PREVIEW_BORDER, self.style.layout("Border.TFrame"))
262261
self.style.configure(_PREVIEW_BORDER, relief="solid")
263262

264-
def _apply_preview_colors(self, theme: ThemePalette):
263+
def _apply_preview_colors(self, theme):
265264
self.style.configure(
266265
_PREVIEW_FRAME,
267266
background=theme.bg,
@@ -319,24 +318,5 @@ def _apply_preview_colors(self, theme: ThemePalette):
319318
)
320319

321320
def _center_dialog(self):
322-
dialog = self._dialog
323-
master = self.master
324-
if not dialog or not master:
325-
return
326-
327-
dialog.update_idletasks()
328-
master.update_idletasks()
329-
330-
if master.winfo_exists():
331-
x0 = master.winfo_rootx()
332-
y0 = master.winfo_rooty()
333-
w0 = master.winfo_width()
334-
h0 = master.winfo_height()
335-
336-
w = dialog.winfo_reqwidth()
337-
h = dialog.winfo_reqheight()
338-
339-
x = x0 + (w0 - w) // 2
340-
y = y0 + (h0 - h) // 2
341-
342-
dialog.geometry(f"{w}x{h}+{x}+{y}")
321+
if self._dialog and self.master and self.master.winfo_exists():
322+
center_on_parent(self._dialog, self.master)

GUI/themed_messagebox.py

Lines changed: 9 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
import tkinter as tk
22

3-
from GUI.widgets.helpers import theme_title_bar
3+
from GUI.widgets.helpers import center_on_parent, theme_title_bar
44
from GUI.widgets.themed_button import ThemedButton
55

6-
INFO_ICON = 0
7-
ERROR_ICON = 1
8-
9-
_ICON_LIST = ("i", "✕")
6+
_ERROR_ICON = "\u2715"
107

118

129
class _ThemedDialog(tk.Toplevel):
@@ -17,7 +14,7 @@ def __init__(
1714
message,
1815
*,
1916
theme,
20-
icon=ERROR_ICON,
17+
icon=_ERROR_ICON,
2118
detail=None,
2219
buttons=None,
2320
default=None,
@@ -55,12 +52,11 @@ def _build_ui(self, message, icon, detail, buttons, default, cancel_value):
5552
if detail:
5653
message = f"{message}\n\n{detail}\n" if message else detail
5754
wrap_length = 260
58-
icon_text = _ICON_LIST[icon]
5955

60-
if icon_text:
56+
if icon:
6157
icon_label = tk.Label(
6258
container,
63-
text=icon_text,
59+
text=icon,
6460
font=("Segoe UI", 18, "bold"),
6561
)
6662
icon_label.grid(row=0, column=0, sticky="n", padx=(0, 12))
@@ -97,8 +93,6 @@ def _build_ui(self, message, icon, detail, buttons, default, cancel_value):
9793

9894
def _apply_minsize(self):
9995
container = self._layout_root
100-
if not container:
101-
return
10296
try:
10397
container.update_idletasks()
10498
required_w = container.winfo_reqwidth()
@@ -116,28 +110,10 @@ def _apply_minsize(self):
116110
self._target_size = (min_w, min_h)
117111

118112
def _center(self, parent):
119-
try:
120-
self.update_idletasks()
121-
except tk.TclError:
122-
pass
123-
124-
try:
125-
parent.update_idletasks()
126-
parent_x = parent.winfo_rootx()
127-
parent_y = parent.winfo_rooty()
128-
parent_w = parent.winfo_width()
129-
parent_h = parent.winfo_height()
130-
except tk.TclError:
131-
parent_x = parent_y = 0
132-
parent_w = self.winfo_screenwidth()
133-
parent_h = self.winfo_screenheight()
134-
135113
target_w, target_h = self._target_size
136-
w = target_w if target_w else self.winfo_width()
137-
h = target_h if target_h else self.winfo_height()
138-
x = parent_x + (parent_w - w) // 2
139-
y = parent_y + (parent_h - h) // 2
140-
self.geometry(f"{w}x{h}+{x}+{y}")
114+
w = target_w if target_w else None
115+
h = target_h if target_h else None
116+
center_on_parent(self, parent, w, h)
141117

142118
def _finish(self, value):
143119
self._result = value
@@ -184,7 +160,7 @@ def show_error(parent, title, message, *, theme: tuple, detail=None):
184160
title,
185161
message,
186162
theme=theme,
187-
icon=ERROR_ICON,
163+
icon=_ERROR_ICON,
188164
buttons=[("OK", True)],
189165
default=True,
190166
cancel_value=True,

GUI/widgets/autoscrollbar.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,7 @@ def grid(self, **kwargs):
2424

2525
def _show(self):
2626
if not self.winfo_ismapped():
27-
if self._grid_kwargs:
28-
super().grid(**self._grid_kwargs)
29-
else:
30-
super().grid()
27+
super().grid(**self._grid_kwargs)
3128

3229
def _hide(self):
3330
if self.winfo_ismapped():

GUI/widgets/find_popup.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import tkinter as tk
22
from tkinter import ttk
33

4-
from dm40.types import ThemePalette
4+
55

66

77
class FindPopup:
@@ -15,7 +15,7 @@ def __init__(
1515
self,
1616
parent: tk.Misc,
1717
text: tk.Text,
18-
colors: ThemePalette,
18+
colors,
1919
*,
2020
grid_opts: dict | None = None,
2121
):
@@ -69,7 +69,7 @@ def __init__(
6969
self.set_tag_colors(self._colors)
7070
self.hide(clear=False)
7171

72-
def set_tag_colors(self, colors: ThemePalette) -> None:
72+
def set_tag_colors(self, colors) -> None:
7373
self._colors = colors
7474
text_fg = colors.text
7575
match_bg = colors.outline

GUI/widgets/helpers.py

Lines changed: 31 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,9 @@
77
_USER32 = ctypes.windll.user32
88
_DWMAPI = ctypes.windll.dwmapi
99
_SHCORE = getattr(ctypes.windll, "shcore", None)
10-
_HAS_DPI_CONTEXT = hasattr(_USER32, "SetProcessDpiAwarenessContext")
11-
_HAS_DPI_AWARENESS = _SHCORE is not None and hasattr(
12-
_SHCORE, "SetProcessDpiAwareness"
13-
)
14-
_HAS_DPI_AWARE = hasattr(_USER32, "SetProcessDPIAware")
1510
except (ImportError, AttributeError, OSError) as exc:
1611
raise ImportError("Required Windows APIs are not available") from exc
1712

18-
DWMWA_BORDER_COLOR = 34
19-
DWMWA_CAPTION_COLOR = 35
20-
2113
class intptr_t(ctypes._SimpleCData):
2214
_type_ = "P"
2315
_csize_ = ctypes.sizeof(ctypes.c_void_p)
@@ -26,13 +18,6 @@ class int32(ctypes._SimpleCData):
2618
_type_ = "i"
2719
_csize_ = 4
2820

29-
_DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = intptr_t(-4)
30-
31-
32-
def _hex_to_rgb(value):
33-
b = bytes.fromhex(value[1:])
34-
return b[0], b[1], b[2]
35-
3621

3722
def _set_window_attribute(hwnd, attribute, value):
3823
if not hwnd:
@@ -45,52 +30,60 @@ def _set_window_attribute(hwnd, attribute, value):
4530
return True
4631

4732

48-
def _colorref_from_hex(value):
49-
r, g, b = _hex_to_rgb(value)
50-
return (b << 16) | (g << 8) | r
33+
def _colorref_from_hex(value, _bf=bytes.fromhex, _ifb=int.from_bytes):
34+
return _ifb(_bf(value[1:]), 'little')
5135

5236

5337
def theme_title_bar(window: tk.Tk | tk.Toplevel, *, border_color: str | None = None,
5438
caption_color: str | None = None) -> bool:
55-
"""Apply theme-aligned border/caption colors to the window title bar."""
5639
window.update_idletasks()
57-
frame = window.wm_frame()
58-
hwnd = int(frame, 16)
40+
hwnd = int(window.wm_frame(), 16)
5941
if border_color is not None:
60-
if not set_title_bar_border_color(border_color, hwnd=hwnd):
42+
val = int32(_colorref_from_hex(border_color)) if border_color else int32(0)
43+
if not _set_window_attribute(hwnd, 34, val):
6144
return False
6245
if caption_color is None:
6346
caption_color = border_color
6447
if caption_color is not None:
65-
if not set_title_bar_caption_color(caption_color, hwnd=hwnd):
48+
val = int32(_colorref_from_hex(caption_color)) if caption_color else int32(0)
49+
if not _set_window_attribute(hwnd, 35, val):
6650
return False
6751
return True
6852

6953

70-
def set_title_bar_border_color(color_hex: str | None, hwnd=None) -> bool:
71-
if not color_hex:
72-
return _set_window_attribute(hwnd, DWMWA_BORDER_COLOR, int32(0))
73-
return _set_window_attribute(hwnd, DWMWA_BORDER_COLOR, int32(_colorref_from_hex(color_hex)))
74-
54+
def center_on_parent(child, parent, w=None, h=None):
55+
child.update_idletasks()
56+
try:
57+
parent.update_idletasks()
58+
px = parent.winfo_rootx()
59+
py = parent.winfo_rooty()
60+
pw = parent.winfo_width()
61+
ph = parent.winfo_height()
62+
except tk.TclError:
63+
px = py = 0
64+
pw = child.winfo_screenwidth()
65+
ph = child.winfo_screenheight()
66+
if w is None:
67+
w = child.winfo_reqwidth()
68+
if h is None:
69+
h = child.winfo_reqheight()
70+
x = px + (pw - w) // 2
71+
y = py + (ph - h) // 2
72+
child.geometry("%dx%d+%d+%d" % (w, h, x, y))
7573

76-
def set_title_bar_caption_color(color_hex: str | None, hwnd=None) -> bool:
77-
if not color_hex:
78-
return _set_window_attribute(hwnd, DWMWA_CAPTION_COLOR, int32(0))
79-
return _set_window_attribute(hwnd, DWMWA_CAPTION_COLOR, int32(_colorref_from_hex(color_hex)))
8074

8175
def ensure_dpi_awareness():
82-
if _HAS_DPI_CONTEXT:
83-
_USER32.SetProcessDpiAwarenessContext(
84-
_DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2
85-
)
76+
if hasattr(_USER32, "SetProcessDpiAwarenessContext"):
77+
_USER32.SetProcessDpiAwarenessContext(intptr_t(-4))
8678
return
87-
if _HAS_DPI_AWARENESS and _SHCORE:
79+
if _SHCORE is not None and hasattr(_SHCORE, "SetProcessDpiAwareness"):
8880
_SHCORE.SetProcessDpiAwareness(2)
89-
elif _HAS_DPI_AWARE:
81+
elif hasattr(_USER32, "SetProcessDPIAware"):
9082
_USER32.SetProcessDPIAware()
9183

9284

9385
__all__ = [
86+
'center_on_parent',
9487
'ensure_dpi_awareness',
9588
'theme_title_bar'
9689
]

0 commit comments

Comments
 (0)