From 20409621871aa7f286e964bd9299fe7abf805aaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maj=20Sokli=C4=8D?= Date: Fri, 20 Mar 2026 19:17:59 +0100 Subject: [PATCH 1/5] Optimize _colorref_from_hex - 70% faster - 4.3x smaller bytecode --- GUI/widgets/helpers.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/GUI/widgets/helpers.py b/GUI/widgets/helpers.py index e231fa1..e2f54da 100644 --- a/GUI/widgets/helpers.py +++ b/GUI/widgets/helpers.py @@ -29,11 +29,6 @@ class int32(ctypes._SimpleCData): _DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = intptr_t(-4) -def _hex_to_rgb(value): - b = bytes.fromhex(value[1:]) - return b[0], b[1], b[2] - - def _set_window_attribute(hwnd, attribute, value): if not hwnd: return False @@ -45,9 +40,8 @@ def _set_window_attribute(hwnd, attribute, value): return True -def _colorref_from_hex(value): - r, g, b = _hex_to_rgb(value) - return (b << 16) | (g << 8) | r +def _colorref_from_hex(value, _bf=bytes.fromhex, _ifb=int.from_bytes): + return _ifb(_bf(value[1:]), 'little') def theme_title_bar(window: tk.Tk | tk.Toplevel, *, border_color: str | None = None, From a82c0f642b458eee1a1ff8d9a1b8a8cc36a99cbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maj=20Sokli=C4=8D?= Date: Fri, 24 Apr 2026 19:45:52 +0200 Subject: [PATCH 2/5] refactor: split into shared transport and device handlers Move the WinRT BLE transport, mini_asyncio runtime, theme store, and shared types out of dm40/ and into shared/, leaving dm40/ as a device-specific handler package. - shared/base_app: single-window Tk app with scan, connect, waveform, raw packet view, and dynamic handler dispatch. - shared/ble_worker: transport worker with a device-family probe used before handler selection. - shared/device_registry: central table mapping advertised names and probe replies to handler modules. - dm40/app: convert to a handler driven by base_app instead of owning the Tk root. --- GUI/controls.py | 7 +- GUI/theme_manager.py | 38 +- GUI/themed_messagebox.py | 42 +- GUI/widgets/autoscrollbar.py | 5 +- GUI/widgets/find_popup.py | 6 +- GUI/widgets/helpers.py | 59 +- dm40/app.py | 783 +++++---------------- dm40/ble_worker.py | 187 ----- dm40/parsing.py | 110 ++- dm40/protocol_constants.py | 9 +- main.py | 8 +- shared/base_app.py | 462 ++++++++++++ shared/ble_worker.py | 235 +++++++ shared/device_registry.py | 35 + {dm40 => shared}/mini_asyncio.py | 12 +- {dm40 => shared}/nanowinbt/client.py | 76 +- {dm40 => shared}/nanowinbt/ctypes_com.py | 0 {dm40 => shared}/nanowinbt/ctypes_winrt.py | 34 +- {dm40 => shared}/nanowinbt/radio.py | 2 - {dm40 => shared}/nanowinbt/scanner.py | 9 +- {dm40 => shared}/theme_store.py | 2 +- {dm40 => shared}/types.py | 0 shims/ctypes_shim.py | 6 +- utils/theme_store_builder.py | 2 +- 24 files changed, 1083 insertions(+), 1046 deletions(-) delete mode 100644 dm40/ble_worker.py create mode 100644 shared/base_app.py create mode 100644 shared/ble_worker.py create mode 100644 shared/device_registry.py rename {dm40 => shared}/mini_asyncio.py (95%) rename {dm40 => shared}/nanowinbt/client.py (81%) rename {dm40 => shared}/nanowinbt/ctypes_com.py (100%) rename {dm40 => shared}/nanowinbt/ctypes_winrt.py (93%) rename {dm40 => shared}/nanowinbt/radio.py (93%) rename {dm40 => shared}/nanowinbt/scanner.py (95%) rename {dm40 => shared}/theme_store.py (96%) rename {dm40 => shared}/types.py (100%) diff --git a/GUI/controls.py b/GUI/controls.py index f711a41..e3770db 100644 --- a/GUI/controls.py +++ b/GUI/controls.py @@ -1,8 +1,5 @@ -from dm40.types import ThemePalette - - class UIControls: - def __init__(self, master, style, theme: ThemePalette): + def __init__(self, master, style, theme): self.master = master self.style = style self.theme = theme @@ -11,7 +8,7 @@ def __init__(self, master, style, theme: ThemePalette): self._init_layouts() self.apply_theme() - def use_theme(self, theme: ThemePalette) -> None: + def use_theme(self, theme) -> None: self.theme = theme self.apply_theme() diff --git a/GUI/theme_manager.py b/GUI/theme_manager.py index ed00cc7..db926d5 100644 --- a/GUI/theme_manager.py +++ b/GUI/theme_manager.py @@ -2,9 +2,8 @@ import tkinter as tk from tkinter import ttk -from dm40.types import ThemePalette -from dm40.theme_store import deserialize_theme_store_palettes -from GUI.widgets.helpers import theme_title_bar +from shared.theme_store import deserialize_theme_store_palettes +from GUI.widgets.helpers import center_on_parent, theme_title_bar from GUI.widgets.themed_button import ThemedButton _PREVIEW_FRAME = "ThemePreview.TFrame" @@ -34,7 +33,7 @@ def __init__( self.style = style self._on_apply = on_apply - self._themes: list[ThemePalette] = deserialize_theme_store_palettes(_DEFAULT_STORE) + self._themes = deserialize_theme_store_palettes(_DEFAULT_STORE) self._active_theme_idx = self._read_active_index() self._dialog = None @@ -64,7 +63,7 @@ def list_theme_names(self) -> list[str]: def get_active_theme_index(self) -> int: return self._active_theme_idx - def get_active_theme(self) -> ThemePalette: + def get_active_theme(self): return self._themes[self._active_theme_idx] def activate_theme_index(self, theme_idx: int): @@ -234,7 +233,7 @@ def _apply_selected_theme(self): self._select_listbox_index(self._active_theme_idx) self._update_preview_from_selection() - def _activate_by_index(self, idx: int) -> tuple[ThemePalette | None, bool]: + def _activate_by_index(self, idx: int): if idx < 0 or idx >= len(self._themes): return None, False if self._active_theme_idx == idx: @@ -246,7 +245,7 @@ def _activate_by_index(self, idx: int) -> tuple[ThemePalette | None, bool]: self._update_listbox_active(old_idx) return self._themes[idx], True - def _apply_dialog_chrome(self, theme: ThemePalette): + def _apply_dialog_chrome(self, theme): dialog = self._dialog if not dialog or not dialog.winfo_exists(): return @@ -261,7 +260,7 @@ def _init_preview_styles(self): self.style.layout(_PREVIEW_BORDER, self.style.layout("Border.TFrame")) self.style.configure(_PREVIEW_BORDER, relief="solid") - def _apply_preview_colors(self, theme: ThemePalette): + def _apply_preview_colors(self, theme): self.style.configure( _PREVIEW_FRAME, background=theme.bg, @@ -319,24 +318,5 @@ def _apply_preview_colors(self, theme: ThemePalette): ) def _center_dialog(self): - dialog = self._dialog - master = self.master - if not dialog or not master: - return - - dialog.update_idletasks() - master.update_idletasks() - - if master.winfo_exists(): - x0 = master.winfo_rootx() - y0 = master.winfo_rooty() - w0 = master.winfo_width() - h0 = master.winfo_height() - - w = dialog.winfo_reqwidth() - h = dialog.winfo_reqheight() - - x = x0 + (w0 - w) // 2 - y = y0 + (h0 - h) // 2 - - dialog.geometry(f"{w}x{h}+{x}+{y}") + if self._dialog and self.master and self.master.winfo_exists(): + center_on_parent(self._dialog, self.master) diff --git a/GUI/themed_messagebox.py b/GUI/themed_messagebox.py index e442295..47e2e68 100644 --- a/GUI/themed_messagebox.py +++ b/GUI/themed_messagebox.py @@ -1,12 +1,9 @@ import tkinter as tk -from GUI.widgets.helpers import theme_title_bar +from GUI.widgets.helpers import center_on_parent, theme_title_bar from GUI.widgets.themed_button import ThemedButton -INFO_ICON = 0 -ERROR_ICON = 1 - -_ICON_LIST = ("i", "✕") +_ERROR_ICON = "\u2715" class _ThemedDialog(tk.Toplevel): @@ -17,7 +14,7 @@ def __init__( message, *, theme, - icon=ERROR_ICON, + icon=_ERROR_ICON, detail=None, buttons=None, default=None, @@ -55,12 +52,11 @@ def _build_ui(self, message, icon, detail, buttons, default, cancel_value): if detail: message = f"{message}\n\n{detail}\n" if message else detail wrap_length = 260 - icon_text = _ICON_LIST[icon] - if icon_text: + if icon: icon_label = tk.Label( container, - text=icon_text, + text=icon, font=("Segoe UI", 18, "bold"), ) 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): def _apply_minsize(self): container = self._layout_root - if not container: - return try: container.update_idletasks() required_w = container.winfo_reqwidth() @@ -116,28 +110,10 @@ def _apply_minsize(self): self._target_size = (min_w, min_h) def _center(self, parent): - try: - self.update_idletasks() - except tk.TclError: - pass - - try: - parent.update_idletasks() - parent_x = parent.winfo_rootx() - parent_y = parent.winfo_rooty() - parent_w = parent.winfo_width() - parent_h = parent.winfo_height() - except tk.TclError: - parent_x = parent_y = 0 - parent_w = self.winfo_screenwidth() - parent_h = self.winfo_screenheight() - target_w, target_h = self._target_size - w = target_w if target_w else self.winfo_width() - h = target_h if target_h else self.winfo_height() - x = parent_x + (parent_w - w) // 2 - y = parent_y + (parent_h - h) // 2 - self.geometry(f"{w}x{h}+{x}+{y}") + w = target_w if target_w else None + h = target_h if target_h else None + center_on_parent(self, parent, w, h) def _finish(self, value): self._result = value @@ -184,7 +160,7 @@ def show_error(parent, title, message, *, theme: tuple, detail=None): title, message, theme=theme, - icon=ERROR_ICON, + icon=_ERROR_ICON, buttons=[("OK", True)], default=True, cancel_value=True, diff --git a/GUI/widgets/autoscrollbar.py b/GUI/widgets/autoscrollbar.py index d2418df..47b461d 100644 --- a/GUI/widgets/autoscrollbar.py +++ b/GUI/widgets/autoscrollbar.py @@ -24,10 +24,7 @@ def grid(self, **kwargs): def _show(self): if not self.winfo_ismapped(): - if self._grid_kwargs: - super().grid(**self._grid_kwargs) - else: - super().grid() + super().grid(**self._grid_kwargs) def _hide(self): if self.winfo_ismapped(): diff --git a/GUI/widgets/find_popup.py b/GUI/widgets/find_popup.py index 3e8c48e..089edb0 100644 --- a/GUI/widgets/find_popup.py +++ b/GUI/widgets/find_popup.py @@ -1,7 +1,7 @@ import tkinter as tk from tkinter import ttk -from dm40.types import ThemePalette + class FindPopup: @@ -15,7 +15,7 @@ def __init__( self, parent: tk.Misc, text: tk.Text, - colors: ThemePalette, + colors, *, grid_opts: dict | None = None, ): @@ -69,7 +69,7 @@ def __init__( self.set_tag_colors(self._colors) self.hide(clear=False) - def set_tag_colors(self, colors: ThemePalette) -> None: + def set_tag_colors(self, colors) -> None: self._colors = colors text_fg = colors.text match_bg = colors.outline diff --git a/GUI/widgets/helpers.py b/GUI/widgets/helpers.py index e2f54da..07024b7 100644 --- a/GUI/widgets/helpers.py +++ b/GUI/widgets/helpers.py @@ -7,17 +7,9 @@ _USER32 = ctypes.windll.user32 _DWMAPI = ctypes.windll.dwmapi _SHCORE = getattr(ctypes.windll, "shcore", None) - _HAS_DPI_CONTEXT = hasattr(_USER32, "SetProcessDpiAwarenessContext") - _HAS_DPI_AWARENESS = _SHCORE is not None and hasattr( - _SHCORE, "SetProcessDpiAwareness" - ) - _HAS_DPI_AWARE = hasattr(_USER32, "SetProcessDPIAware") except (ImportError, AttributeError, OSError) as exc: raise ImportError("Required Windows APIs are not available") from exc -DWMWA_BORDER_COLOR = 34 -DWMWA_CAPTION_COLOR = 35 - class intptr_t(ctypes._SimpleCData): _type_ = "P" _csize_ = ctypes.sizeof(ctypes.c_void_p) @@ -26,8 +18,6 @@ class int32(ctypes._SimpleCData): _type_ = "i" _csize_ = 4 -_DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = intptr_t(-4) - def _set_window_attribute(hwnd, attribute, value): if not hwnd: @@ -46,45 +36,54 @@ def _colorref_from_hex(value, _bf=bytes.fromhex, _ifb=int.from_bytes): def theme_title_bar(window: tk.Tk | tk.Toplevel, *, border_color: str | None = None, caption_color: str | None = None) -> bool: - """Apply theme-aligned border/caption colors to the window title bar.""" window.update_idletasks() - frame = window.wm_frame() - hwnd = int(frame, 16) + hwnd = int(window.wm_frame(), 16) if border_color is not None: - if not set_title_bar_border_color(border_color, hwnd=hwnd): + val = int32(_colorref_from_hex(border_color)) if border_color else int32(0) + if not _set_window_attribute(hwnd, 34, val): return False if caption_color is None: caption_color = border_color if caption_color is not None: - if not set_title_bar_caption_color(caption_color, hwnd=hwnd): + val = int32(_colorref_from_hex(caption_color)) if caption_color else int32(0) + if not _set_window_attribute(hwnd, 35, val): return False return True -def set_title_bar_border_color(color_hex: str | None, hwnd=None) -> bool: - if not color_hex: - return _set_window_attribute(hwnd, DWMWA_BORDER_COLOR, int32(0)) - return _set_window_attribute(hwnd, DWMWA_BORDER_COLOR, int32(_colorref_from_hex(color_hex))) - +def center_on_parent(child, parent, w=None, h=None): + child.update_idletasks() + try: + parent.update_idletasks() + px = parent.winfo_rootx() + py = parent.winfo_rooty() + pw = parent.winfo_width() + ph = parent.winfo_height() + except tk.TclError: + px = py = 0 + pw = child.winfo_screenwidth() + ph = child.winfo_screenheight() + if w is None: + w = child.winfo_reqwidth() + if h is None: + h = child.winfo_reqheight() + x = px + (pw - w) // 2 + y = py + (ph - h) // 2 + child.geometry("%dx%d+%d+%d" % (w, h, x, y)) -def set_title_bar_caption_color(color_hex: str | None, hwnd=None) -> bool: - if not color_hex: - return _set_window_attribute(hwnd, DWMWA_CAPTION_COLOR, int32(0)) - return _set_window_attribute(hwnd, DWMWA_CAPTION_COLOR, int32(_colorref_from_hex(color_hex))) def ensure_dpi_awareness(): - if _HAS_DPI_CONTEXT: - _USER32.SetProcessDpiAwarenessContext( - _DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 - ) + if hasattr(_USER32, "SetProcessDpiAwarenessContext"): + _USER32.SetProcessDpiAwarenessContext(intptr_t(-4)) return - if _HAS_DPI_AWARENESS and _SHCORE: + if _SHCORE is not None and hasattr(_SHCORE, "SetProcessDpiAwareness"): _SHCORE.SetProcessDpiAwareness(2) - elif _HAS_DPI_AWARE: + elif hasattr(_USER32, "SetProcessDPIAware"): _USER32.SetProcessDPIAware() __all__ = [ + 'center_on_parent', 'ensure_dpi_awareness', 'theme_title_bar' ] diff --git a/dm40/app.py b/dm40/app.py index f90dcb2..7d7bf10 100644 --- a/dm40/app.py +++ b/dm40/app.py @@ -1,27 +1,14 @@ -"""DM40 Tkinter application.""" - -import _thread -import time +"""DM40 device handler.""" import tkinter as tk from tkinter import ttk -from . import mini_asyncio as asyncio - -from dm40.types import ThemePalette -from GUI.controls import UIControls -from GUI.theme_manager import ThemeManager -from GUI.themed_messagebox import show_error -from GUI.widgets.autoscrollbar import AutoScrollbar -from GUI.widgets.find_popup import FindPopup -from GUI.widgets.helpers import ensure_dpi_awareness, theme_title_bar -from GUI.widgets.menubar import MENU_UP, MenuDropdown, OwnerDrawnMenuBar -from GUI.widgets.themed_button import ThemedButton -from GUI.widgets.waveform_view import WaveformView - -from .ble_worker import BleWorker -from .nanowinbt.scanner import NanoScanner -from .parsing import MODEL, Measurement, parse_device_status, parse_measurement_for_ui +from shared.ble_worker import BleWorker +from GUI.widgets.menubar import MENU_UP, MenuDropdown + +from .parsing import MODEL, MODEL_TABLE, Measurement, parse_device_status, parse_measurement_for_ui from .protocol_constants import ( + CMD_ID, + CMD_READ, COMMAND_CYCLE_GROUPS, COMMAND_KIND_LABELS, COMMAND_KIND_TO_GROUP, @@ -38,313 +25,135 @@ for _flag, (_kind_name, _rng) in FLAG_INFO.items(): _RANGE_ITEMS_BY_KIND.setdefault(_kind_name, []).append((_rng, _flag)) - -def _build_command_packet(cmd_prefix: bytes) -> bytes: - checksum = (-sum(cmd_prefix)) & 0xFF - return b"%b%c" % (cmd_prefix, checksum) +_MODEL_PREFIX = b"\xdf\x05\x03\x08\x14" -class DM40App(tk.Tk): - def __init__(self): - super().__init__() - ensure_dpi_awareness() - - self._start_time = time.monotonic() - self._title_base = "DM40" - self.title(self._title_base) - self.minsize(916, 650) - self.wm_geometry("916x650") +def _dm40_notify_filter(data: bytes) -> bool: + if data[:5] == _MODEL_PREFIX: + idx = data[9] - 0x41 + if 0 <= idx < len(MODEL_TABLE): + MODEL.model_name, MODEL.device_counts = MODEL_TABLE[idx] + return False + return True - self.style = ttk.Style(self) - self._theme_manager = ThemeManager(self, self.style, self._apply_theme) - initial_theme = self._theme_manager.get_active_theme() - self.ui = UIControls(self, self.style, theme=initial_theme) - theme_title_bar( - self, - border_color=initial_theme.outline, - caption_color=initial_theme.bg, - ) +class DM40Handler: + title = "DM40" + csv_prefix = "DM40" - self._worker: BleWorker | None = None + def __init__(self, app) -> None: + self.app = app self._last_trace_key: int | None = None self._last_device_status: tuple = (0, False, False, False) - self._devices: list = [] - self._device_index_by_address: dict[str, int] = {} - self._scan_in_progress = False - self._scan_generation = 0 - self._scan_cancel = None + self._last_measurement: Measurement | None = None self._mode_buttons: list[ttk.Button | ttk.Checkbutton] = [] self._range_button: ttk.Button | None = None self._range_menu: MenuDropdown | None = None self._toggle_vars: dict[str, tk.BooleanVar] = { - label: tk.BooleanVar(value=False) - for label in ("AUTO", "HOLD", "CAP") + label: tk.BooleanVar(value=False) for label in ("AUTO", "HOLD", "CAP") } self._cycle_groups: dict = {} self._last_base_mode_flag: int | None = None - self._last_measurement: Measurement | None = None - self._is_connected = False - - self._stats_count = 0 - self._stats_sum = 0.0 - self._stats_min = 0.0 - self._stats_max = 0.0 - - self._build_ui() - self.bind_all("", self._toggle_wave_pause) - self.bind_all("", self._save_wave_csv) - self.bind_all("", self._toggle_wave_record) - self.bind_all("", self._copy_reading) - - def _toggle_wave_pause(self, _event=None) -> None: - self._wave_view.toggle_pause() - self._refresh_status_bar() - - def _copy_reading(self, _event=None) -> None: - source = _event.widget if _event is not None else self.focus_get() - if isinstance(source, (tk.Text, tk.Entry, ttk.Entry)): - return - - m = self._last_measurement - if not m or m.kind == "---": - return - unit = f" {m.display_unit}" if m.display_unit else "" - self.clipboard_clear() - self.clipboard_append(f"{m.value_str}{unit}") - - _CSV_DIR = __compiled__.containing_dir if '__compiled__' in globals() else __file__.rsplit('\\', 2)[0] # type: ignore[name-defined] - - def _csv_path(self, prefix: str) -> str: - return f"{self._CSV_DIR}\\{prefix}_{time.strftime('%Y%m%d_%H%M%S')}.csv" - - def _save_wave_csv(self, _event=None) -> None: - self._wave_view.save_buffer_csv(self._csv_path("DM40")) - def _toggle_wave_record(self, _event=None) -> None: - if self._wave_view.recording: - self._wave_view.stop_recording() - else: - self._wave_view.toggle_recording(self._csv_path("DM40_rec")) - self._refresh_status_bar() - - def _clear_capture_data(self) -> None: - self._wave_view.clear() - self._raw_text.configure(state="normal") - self._raw_text.delete("1.0", "end") - self._raw_text.configure(state="disabled") - self._last_trace_key = None - self._stats_count = 0 - self._stats_sum = 0.0 - self._stats_min = 0.0 - self._stats_max = 0.0 - self._stats_var.set("") - self._refresh_status_bar() - - def _build_ui(self) -> None: - self._menu_bar = OwnerDrawnMenuBar( - self, - menus=[ - ( - "File", - [ - ("Save Buffer", self._save_wave_csv), - ("Record", self._toggle_wave_record), - "separator", - ("Clear", self._clear_capture_data), - "separator", - ("Exit", self._on_close), - ], - ), - ("Themes", []), - ], - theme_manager=self._theme_manager, - on_theme=self._apply_theme, + def create_worker(self, device, **callbacks): + return BleWorker( + device, + poll_cmd=CMD_READ, + init_cmd=CMD_ID, + notify_hook=_dm40_notify_filter, + write_buf_size=7, + **callbacks, ) - self._menu_bar.pack(fill=tk.X) - self._menu_bar.grid_columnconfigure(2, weight=1) - - root = tk.Frame(self, padx=12, pady=12) - root.pack(fill=tk.BOTH, expand=True) - root.columnconfigure(0, weight=1) + def build_menubar_labels(self, pre: tk.Frame, post: tk.Frame) -> None: self._model_var = tk.StringVar(value=MODEL.model_name) tk.Label( - self._menu_bar, textvariable=self._model_var, font=("Segoe UI", 11, "bold") - ).grid(row=0, column=3, sticky="e") + pre, textvariable=self._model_var, font=("Segoe UI", 11, "bold") + ).pack(side="left") self._runtime_var = tk.StringVar(value="") - tk.Label(self._menu_bar, textvariable=self._runtime_var).grid( - row=0, column=4, sticky="w", padx=(0, 0) - ) + tk.Label(pre, textvariable=self._runtime_var).pack(side="left", padx=(8, 0)) self._icons_var = tk.StringVar(value="") - tk.Label(self._menu_bar, textvariable=self._icons_var).grid( - row=0, column=5, sticky="e", padx=(0, 0) - ) - self._battery_label = tk.Label( - self._menu_bar, - text="", - font=("Consolas", 10), - ) - self._battery_label.grid(row=0, column=6, sticky="e", padx=(0, 8)) - - self._status_var = tk.StringVar(value="Disconnected") - mid = tk.Frame(root) - mid.grid(row=1, column=0, sticky="nsew") - mid.columnconfigure(0, weight=1) - root.rowconfigure(1, weight=3) + tk.Label(post, textvariable=self._icons_var).pack(side="left") + self._battery_label = tk.Label(post, text="", font=("Consolas", 10)) + self._battery_label.pack(side="left", padx=(0, 8)) - reading = tk.Frame(mid) - reading.grid(row=0, column=0, sticky="ew") - reading.columnconfigure(0, weight=1) + def build_reading_area(self, parent: tk.Frame) -> None: + parent.columnconfigure(0, weight=0) + parent.columnconfigure(1, weight=1) self._mode_var = tk.StringVar(value="---") tk.Label( - reading, textvariable=self._mode_var, font=("Segoe UI", 12, "bold") + parent, textvariable=self._mode_var, font=("Segoe UI", 12, "bold") ).grid(row=0, column=0, sticky="w") self._value_label = ttk.Label( - reading, + parent, text="---", font=("Cascadia Mono", 44, "bold"), style="DM40.BigValue.TLabel", width=12, anchor="e", ) - self._value_label.grid(row=1, column=0, sticky="w") - self._aux_var = tk.StringVar(value="") - tk.Label(reading, textvariable=self._aux_var, font=("Segoe UI", 11)).grid( - row=2, column=0, columnspan=2, sticky="w" - ) - - self._wave_view = WaveformView(mid, colors=self.ui.theme, capacity=600) - self._wave_view.grid(row=1, column=0, sticky="nsew", pady=(10, 0)) - mid.rowconfigure(1, weight=1) - - self._stats_var = tk.StringVar(value="") - tk.Label(mid, textvariable=self._stats_var, font=("Consolas", 10), - anchor="w").grid(row=2, column=0, sticky="ew", pady=(0, 10)) - - raw_frame = tk.Frame(root) - raw_frame.grid(row=2, column=0, sticky="nsew") - raw_frame.columnconfigure(0, weight=1) - raw_frame.rowconfigure(1, weight=1) - root.rowconfigure(2, weight=1) - - tk.Label(raw_frame, text="Raw packets:").grid(row=0, column=0, sticky="w") - - self._raw_text = tk.Text( - raw_frame, - wrap="none", - height=10, - relief="flat", - highlightthickness=2, - undo=False, - maxundo=0, - autoseparators=False - ) - self._raw_text.grid(row=1, column=0, sticky="nsew", pady=(6, 0)) - - yscroll = AutoScrollbar( - raw_frame, - orient="vertical", - command=self._raw_text.yview, - style="Arrowless.Vertical.TScrollbar", - ) - yscroll.grid(row=1, column=1, sticky="ns", pady=(6, 0)) - self._raw_text.configure(yscrollcommand=yscroll.set) - self._raw_text.configure(state="disabled") - - device_panel = tk.Frame(raw_frame) - device_panel.grid(row=0, column=2, rowspan=2, sticky="nsw", padx=(12, 0)) - device_panel.rowconfigure(1, weight=1) - device_panel.columnconfigure(0, weight=0) - device_panel.columnconfigure(1, weight=1) - device_panel.columnconfigure(2, weight=0) - - tk.Label(device_panel, text="Devices:").grid(row=0, column=0, sticky="w") - status_label = tk.Label(device_panel, textvariable=self._status_var) - status_label.grid(row=0, column=1, columnspan=2, sticky="e") - self._device_listbox = tk.Listbox( - device_panel, - height=10, - width=58, - exportselection=False, - activestyle="none", - highlightthickness=2, - relief="flat", - ) - - def on_click(event): - index = self._device_listbox.nearest(event.y) - bbox = self._device_listbox.bbox(index) - if bbox is None or event.y > bbox[1] + bbox[3]: - return "break" - - self._device_listbox.bind("", on_click) - - self._device_listbox.grid( - row=1, column=0, columnspan=3, sticky="nsew", pady=(6, 6) - ) - scan_btn = ThemedButton(device_panel, text="Scan", command=self.scan_devices) - scan_btn.grid(row=2, column=0, sticky="w") - self._connect_btn = ThemedButton( - device_panel, text="Connect", command=self.connect - ) - self._connect_btn.grid(row=2, column=1, sticky="e", padx=(0, 6)) - self._disconnect_btn = ThemedButton( - device_panel, text="Disconnect", command=self.disconnect - ) - self._disconnect_btn.grid(row=2, column=2, sticky="e") - - self._find = FindPopup( - raw_frame, - self._raw_text, - self.ui.theme, - grid_opts={ - "row": 1, - "column": 0, - "sticky": "ne", - "padx": 6, - "pady": (10, 0), - }, - ) - - bar = tk.Frame(root) - bar.grid(row=3, column=0, sticky="w", pady=(8, 0)) - root.rowconfigure(3, weight=0) + self._aux1_var = tk.StringVar(value="") + self._aux2_var = tk.StringVar(value="") + aux_frame = tk.Frame(parent) + aux_frame.grid(row=2, column=0, sticky="e") + tk.Label( + aux_frame, + textvariable=self._aux1_var, + font=("Cascadia Mono", 12, "bold"), + width=12, + anchor="e", + ).pack(side="right", padx=(6, 0)) + tk.Label( + aux_frame, + textvariable=self._aux2_var, + font=("Cascadia Mono", 12, "bold"), + width=12, + anchor="e", + ).pack(side="right") + def build_control_bar(self, bar: tk.Frame) -> None: self._range_button = ttk.Button( bar, text="Range", style="MenuBar.TButton", command=self._show_range_menu, padding=6, - width=0 + width=0, ) self._range_button.pack(side=tk.LEFT, padx=(0, 6)) for label, cmd in MOMENTARY_COMMANDS: - btn = ttk.Button(bar, text=label, style="MenuBar.TButton", - command=lambda c=cmd: self._send_command_prefix(c), padding=6, width=0) + btn = ttk.Button( + bar, text=label, style="MenuBar.TButton", + command=lambda c=cmd: self.app.send_command(c), padding=6, width=0, + ) btn.pack(side=tk.LEFT, padx=(0, 6)) self._mode_buttons.append(btn) for label in ("AUTO", "HOLD"): var = self._toggle_vars[label] - btn = ttk.Checkbutton(bar, text=label, style="MenuBar.TCheckbutton", - variable=var, command=lambda key=label: self._on_toggle_clicked(key)) + btn = ttk.Checkbutton( + bar, text=label, style="MenuBar.TCheckbutton", + variable=var, command=lambda key=label: self._on_toggle_clicked(key), + ) btn.pack(side=tk.LEFT, padx=(0, 6)) self._mode_buttons.append(btn) def add_cycle_group(kind: str, key: str, options: tuple) -> None: var = tk.StringVar(value=options[0][0]) sel_var = tk.BooleanVar(value=False) - self._cycle_groups[key] = {"kind": kind, "options": options, - "label_var": var, "select_var": sel_var} - btn = ttk.Checkbutton(bar, textvariable=var, style="MenuBar.TCheckbutton", - variable=sel_var, command=lambda k=key: self._cycle_mode(k)) + self._cycle_groups[key] = { + "kind": kind, "options": options, + "label_var": var, "select_var": sel_var, + } + btn = ttk.Checkbutton( + bar, textvariable=var, style="MenuBar.TCheckbutton", + variable=sel_var, command=lambda k=key: self._cycle_mode(k), + ) btn.pack(side=tk.LEFT, padx=(0, 6)) self._mode_buttons.append(btn) @@ -352,36 +161,106 @@ def add_cycle_group(kind: str, key: str, options: tuple) -> None: add_cycle_group("range", key, options) var = self._toggle_vars["CAP"] - btn = ttk.Checkbutton(bar, text="CAP", style="MenuBar.TCheckbutton", - variable=var, command=lambda: self._on_toggle_clicked("CAP")) + btn = ttk.Checkbutton( + bar, text="CAP", style="MenuBar.TCheckbutton", + variable=var, command=lambda: self._on_toggle_clicked("CAP"), + ) btn.pack(side=tk.LEFT, padx=(0, 6)) self._mode_buttons.append(btn) for key, options in COMMAND_CYCLE_GROUPS: add_cycle_group("command", key, options) - self._set_control_state(False) + self.set_control_state(False) - self.protocol("WM_DELETE_WINDOW", self._on_close) + self.app.bind_all("", self._copy_reading) - def _apply_theme(self, theme: ThemePalette) -> None: - self.ui.use_theme(theme) - theme_title_bar(self, border_color=theme.outline, caption_color=theme.bg) - - self._wave_view.set_colors(self.ui.theme) - self._find.set_tag_colors(self.ui.theme) - - def _set_control_state(self, enabled: bool) -> None: + def set_control_state(self, enabled: bool) -> None: state = "!disabled" if enabled else "disabled" for btn in self._mode_buttons: btn.state([state]) if self._range_button is not None: self._range_button.state([state]) - if not enabled and self._range_menu is not None: self._range_menu.destroy() self._range_menu = None + def pre_connect_reset(self) -> None: + self._last_trace_key = None + + clear_capture = pre_connect_reset + + def on_connected(self) -> None: + self._model_var.set(MODEL.model_name) + + def teardown(self) -> None: + self.app.unbind_all("") + + def refresh_status(self, now: float) -> None: + app = self.app + if app._is_connected: + elapsed = int(now - app._start_time) + self._runtime_var.set( + "RUN %02d:%02d:%02d" % (elapsed // 3600, (elapsed % 3600) // 60, elapsed % 60) + ) + else: + self._runtime_var.set("") + status = self._last_device_status + self._icons_var.set( + ("⚡" if status[1] else "") + + ("🔒" if status[2] else "") + + ("✋" if status[3] else "") + ) + charging = status[1] + target_fg = "#00ff00" if charging else self.app.option_get("foreground", ".") + if self._battery_label.cget("foreground") != target_fg: + self._battery_label.configure(foreground=target_fg) + segments = max(0, min(5, int(status[0]))) + self._battery_label.configure(text="█" * segments + "░" * (5 - segments)) + + def on_packet(self, data: bytes) -> None: + app = self.app + m = parse_measurement_for_ui(data) + app._append_raw_text(f"RX {m.raw} CRC:{m.crc_str}\n") + + if m.kind == "---": + return + + self._last_device_status = parse_device_status(data) + self._last_measurement = m + self._apply_meter_state() + + trace_key = data[5] + if self._last_trace_key is not None and trace_key != self._last_trace_key: + app._wave_view.clear() + app._stats_count = 0 + self._last_trace_key = trace_key + + self._mode_var.set(f"{m.kind} {m.range}" if m.range else m.kind) + unit = f" {m.display_unit}" if m.display_unit else "" + self._value_label.configure(text=f" {m.value_str}{unit}") + + third = f"{m.third_val} {m.third_unit}".strip() if m.third_val else "" + sec = f"{m.sec_val} {m.sec_unit}".strip() if m.sec_val else "" + self._aux1_var.set(sec) + self._aux2_var.set(third) + + mul = UNIT_TO_BASE[m.display_unit] + if not m.overload: + app._wave_view.push( + m.norm_value, pad=m.vertical_pad, axis_unit=m.display_unit, + axis_mul=mul, decimals=m.decimals, + ) + smin, smax, avg = app._push_stats(m.norm_value) + d = m.decimals + app._stats_var.set( + "Min %.*f Max %.*f Avg %.*f%s" + % (d, smin / mul, d, smax / mul, d, avg / mul, unit) + ) + + if app._is_connected: + app._rate_count += 1 + def _apply_meter_state(self) -> None: m = self._last_measurement if not m: @@ -391,9 +270,8 @@ def _apply_meter_state(self) -> None: group["select_var"].set(False) return - status = self._last_device_status self._toggle_vars["AUTO"].set((m.range or "").startswith("AUTO")) - self._toggle_vars["HOLD"].set(status[3]) + self._toggle_vars["HOLD"].set(self._last_device_status[3]) self._toggle_vars["CAP"].set(m.kind == "CAP") for group in self._cycle_groups.values(): @@ -402,7 +280,6 @@ def _apply_meter_state(self) -> None: kind = m.kind if kind in RANGE_KIND_TO_GROUP: self._select_cycle_group(RANGE_KIND_TO_GROUP[kind], kind) - if kind in COMMAND_KIND_TO_GROUP: label = COMMAND_KIND_LABELS[kind] if kind in COMMAND_KIND_LABELS else kind self._select_cycle_group(COMMAND_KIND_TO_GROUP[kind], label) @@ -414,18 +291,6 @@ def _select_cycle_group(self, key: str, label: str) -> None: group["label_var"].set(label) group["select_var"].set(True) - def _send_command_prefix(self, cmd_prefix: bytes) -> None: - if not self._worker or not self._worker.alive: - show_error( - self, - "Command", - "Connect to a device before sending commands.", - theme=(self.ui.theme.bg, self.ui.theme.outline), - ) - return - payload = _build_command_packet(cmd_prefix) - self._worker.set_command(payload) - def _cycle_mode(self, key: str) -> None: group = self._cycle_groups[key] options = group["options"] @@ -440,34 +305,30 @@ def _cycle_mode(self, key: str) -> None: current_index = (current_index + 1) % len(options) value = options[current_index][1] if isinstance(value, bytes): - self._send_command_prefix(value) + self.app.send_command(value) elif group["kind"] == "range" and isinstance(value, int): self._send_range_flag(value) - self._apply_meter_state() def _on_toggle_clicked(self, key: str) -> None: is_on = self._toggle_vars[key].get() cmd_on, cmd_off = _TOGGLE_COMMANDS_MAP[key] - if key == "CAP": self._toggle_vars[key].set(True) if cmd_on: - self._send_command_prefix(cmd_on) + self.app.send_command(cmd_on) elif is_on and cmd_on: - self._send_command_prefix(cmd_on) + self.app.send_command(cmd_on) elif not is_on: if cmd_off: - self._send_command_prefix(cmd_off) + self.app.send_command(cmd_off) elif self._last_base_mode_flag is not None: self._send_range_flag(self._last_base_mode_flag) - self._apply_meter_state() def _send_range_flag(self, flag: int) -> None: self._last_base_mode_flag = flag - cmd_prefix = b"\xaf\x05\x03\x06\x01%c" % flag - self._send_command_prefix(cmd_prefix) + self.app.send_command(b"\xaf\x05\x03\x06\x01%c" % flag) def _get_active_range_kind(self) -> str | None: if self._last_measurement: @@ -479,7 +340,6 @@ def _get_active_range_kind(self) -> str | None: def _build_range_items(self, kind: str | None) -> list: if not kind or kind not in _RANGE_ITEMS_BY_KIND: return [("No ranges", None)] - return [ (rng, lambda f=flag: self._send_range_flag(f)) for rng, flag in _RANGE_ITEMS_BY_KIND[kind] @@ -490,316 +350,25 @@ def _show_range_menu(self) -> None: self._range_menu.destroy() self._range_menu = None return - if self._range_button is None: return btn = self._range_button active_kind = self._get_active_range_kind() - root = self.winfo_toplevel() self._range_menu = MenuDropdown( - root, + self.app, self._build_range_items(active_kind), - on_destroy=lambda: setattr(self, '_range_menu', None), + on_destroy=lambda: setattr(self, "_range_menu", None), owner_widget=btn, direction=MENU_UP, ) - def _ensure_radio_available(self, title: str) -> bool: - if NanoScanner.radio_state() != "off": - return True - - message = "Bluetooth radio is OFF. Turn Bluetooth on and try again." - self._status_var.set(message) - show_error( - self, - title, - message, - theme=(self.ui.theme.bg, self.ui.theme.outline), - ) - return False - - def connect(self) -> None: - if self._scan_in_progress: - self._scan_generation += 1 - self._scan_in_progress = False - cancel_scan, self._scan_cancel = self._scan_cancel, None - if cancel_scan is not None: - try: - cancel_scan() - except Exception: - pass - if not self._ensure_radio_available("Connect"): - return - - selection = self._device_listbox.curselection() - device = self._devices[selection[0]] if selection else None - if device is None: - show_error( - self, - "Connect", - "Select a device from the list.", - theme=(self.ui.theme.bg, self.ui.theme.outline), - ) - return - if self._worker: - if self._worker.alive: - return - self._worker = None - - self._last_trace_key = None - self._wave_view.clear() - label = device.name or device.address - self._status_var.set(f"Connecting to {label} …") - - def _ui(method): - def _dispatch(*a): - self.after(0, method, *a) - return _dispatch - - self._worker = BleWorker( - device, - on_packet=_ui(self._on_worker_packet), - on_tx=_ui(self._on_worker_tx), - on_status=_ui(self._on_worker_status), - on_error=_ui(self._on_worker_error), - on_connected=_ui(self._on_worker_connected), - on_disconnected=_ui(self._on_worker_disconnected), - ) - - def scan_devices(self) -> None: - if self._scan_in_progress: - return - if not self._ensure_radio_available("Scan"): - return - self._scan_generation += 1 - scan_id = self._scan_generation - self._scan_in_progress = True - self._scan_cancel = None - self._devices = [] - self._device_index_by_address.clear() - self._device_listbox.delete(0, tk.END) - self._status_var.set("Scanning for devices …") - _thread.start_new_thread(self._scan_worker, (scan_id,)) - - def _scan_worker(self, scan_id: int) -> None: - def on_device(device) -> None: - self.after(0, self._scan_add_device, scan_id, device) - - def register_cancel(cancel_scan) -> None: - if scan_id == self._scan_generation: - self._scan_cancel = cancel_scan - else: - cancel_scan() - - try: - devices = asyncio.run( - NanoScanner.discover( - scanning_mode="active", - on_device=on_device, - timeout=3.0, - on_cancel_register=register_cancel, - require_radio_check=False, - ) - ) - except Exception as exc: - self.after(0, self._scan_failed, scan_id, exc) - return - self.after(0, self._scan_complete, scan_id, devices or []) - - def _scan_add_device(self, scan_id: int, device) -> None: - if scan_id != self._scan_generation: - return - addr = device.address - if not addr: - return - name = device.name or "Unknown" - - existing_index = self._device_index_by_address.get(addr) - if existing_index is not None: - existing_device = self._devices[existing_index] - existing_device.name = device.name - - current_label = self._device_listbox.get(existing_index) - desired_label = f"{name} ({addr})" - # Refresh row when new packets provide a better name for an existing address. - if name != "Unknown" and current_label != desired_label: - self._device_listbox.delete(existing_index) - self._device_listbox.insert(existing_index, desired_label) - return - - self._device_index_by_address[addr] = len(self._devices) - self._devices.append(device) - self._device_listbox.insert(tk.END, f"{name} ({addr})") - - def _scan_failed(self, scan_id: int, exc: Exception) -> None: - if scan_id != self._scan_generation: - return - self._scan_in_progress = False - self._scan_cancel = None - self._status_var.set("Scan failed") - show_error( - self, - "Scan", - f"BLE scan failed: {exc!r}", - theme=(self.ui.theme.bg, self.ui.theme.outline), - ) - - def _scan_complete(self, scan_id: int, devices: list) -> None: - if scan_id != self._scan_generation: + def _copy_reading(self, _event=None) -> None: + source = _event.widget if _event is not None else self.app.focus_get() + if isinstance(source, (tk.Text, tk.Entry, ttk.Entry)): return - self._scan_in_progress = False - self._scan_cancel = None - for device in devices: - self._scan_add_device(scan_id, device) - - count = len(self._devices) - self._status_var.set(f"Found {count} device{'s' if count != 1 else ''}") - - def disconnect(self) -> None: - if self._worker: - self._worker.stop() - self._worker = None - self._status_var.set("Disconnected") - self._is_connected = False - self._rate_count = 0 - self._set_control_state(False) - self._refresh_status_bar() - - def _on_close(self) -> None: - self.disconnect() - self.destroy() - - def _on_worker_packet(self, payload: bytes) -> None: - self._apply_packet(payload) - self._refresh_status_bar() - - def _on_worker_tx(self, payload: bytes) -> None: - raw = payload.hex(" ").upper() - self._append_raw_text(f"TX {raw}\n") - self._refresh_status_bar() - - def _on_worker_status(self, message: str) -> None: - self._status_var.set(message) - self._refresh_status_bar() - - def _on_worker_connected(self, _address: str) -> None: - self._is_connected = True - self._model_var.set(MODEL.model_name) - self._rate_count = 0 - self._rate_start = time.monotonic() - self._set_control_state(True) - self._refresh_status_bar() - - def _on_worker_disconnected(self, _address: str) -> None: - self._is_connected = False - self._rate_count = 0 - self._status_var.set("Disconnected") - self._worker = None - self._set_control_state(False) - self._refresh_status_bar() - - def _on_worker_error(self, message: str) -> None: - self._status_var.set(message) - show_error( - self, "DM40", message, theme=(self.ui.theme.bg, self.ui.theme.outline) - ) - self._set_control_state(False) - self._refresh_status_bar() - - def _append_raw_text(self, text: str) -> None: - self._raw_text.configure(state="normal") - self._raw_text.insert("end", text) - self._raw_text.see("end") - self._raw_text.configure(state="disabled") - - def _apply_packet(self, data: bytes) -> None: - m = parse_measurement_for_ui(data) - - self._append_raw_text(f"RX {m.raw} CRC:{'PASS' if m.crc_ok else 'FAIL'}\n") - - if m.kind == "---": + m = self._last_measurement + if not m or m.kind == "---": return - - self._last_device_status = parse_device_status(data) - self._last_measurement = m - self._apply_meter_state() - - trace_key = data[5] - if self._last_trace_key is not None and trace_key != self._last_trace_key: - self._wave_view.clear() - self._stats_count = 0 - self._last_trace_key = trace_key - - self._mode_var.set(f"{m.kind} {m.range}" if m.range else m.kind) unit = f" {m.display_unit}" if m.display_unit else "" - self._value_label.configure(text=f" {m.value_str}{unit}") - - aux = [ - f"{val} {u}".strip() - for val, u in ((m.sec_val, m.sec_unit), (m.third_val, m.third_unit)) - if val - ] - self._aux_var.set(" ".join(aux)) - mul = UNIT_TO_BASE.get(m.display_unit, 1.0) - - if not m.overload and m.norm_value is not None: - self._wave_view.push( - m.norm_value, - pad=m.vertical_pad, - axis_unit=m.display_unit, - axis_mul=mul, - decimals=m.decimals, - ) - v = m.norm_value - if self._stats_count == 0: - self._stats_min = self._stats_max = self._stats_sum = v - else: - if v < self._stats_min: - self._stats_min = v - if v > self._stats_max: - self._stats_max = v - self._stats_sum += v - self._stats_count += 1 - avg = self._stats_sum / self._stats_count - d = m.decimals - self._stats_var.set( - "Min %.*f Max %.*f Avg %.*f%s" - % (d, self._stats_min / mul, d, self._stats_max / mul, d, avg / mul, unit) - ) - - if self._is_connected: - self._rate_count += 1 - - def _refresh_status_bar(self) -> None: - now = time.monotonic() - elapsed = int(now - self._start_time) - h = elapsed // 3600 - m = (elapsed % 3600) // 60 - s = elapsed % 60 - run_s = "RUN %02d:%02d:%02d" % (h, m, s) - self._runtime_var.set(run_s) - - status = self._last_device_status - self._icons_var.set(("⚡" if status[1] else "") + ("🔒" if status[2] else "") + ("✋" if status[3] else "")) - - charging = status[1] - target_fg = "#00ff00" if charging else self.option_get("foreground", ".") - if self._battery_label.cget("foreground") != target_fg: - self._battery_label.configure(foreground=target_fg) - - segments = max(0, min(5, int(status[0]))) - self._battery_label.configure(text="█" * segments + "░" * (5 - segments)) - if self._is_connected: - dt = now - self._rate_start - title_rate = self._rate_count / dt if dt > 0 else 0.0 - if dt >= 2.0: - self._rate_count = 0 - self._rate_start = now - title = f"{self._title_base} - {title_rate:.1f} samples/s" - else: - title = self._title_base - if self._wave_view.paused: - title += " \u23F8 PAUSED" - if self._wave_view.recording: - title += " \u23FA REC" - self.title(title) \ No newline at end of file + self.app.clipboard_clear() + self.app.clipboard_append(f"{m.value_str}{unit}") diff --git a/dm40/ble_worker.py b/dm40/ble_worker.py deleted file mode 100644 index dc206f6..0000000 --- a/dm40/ble_worker.py +++ /dev/null @@ -1,187 +0,0 @@ -"""BLE transport worker for DM40.""" - -import _thread -import time - -from . import mini_asyncio as asyncio - -from .nanowinbt.client import NanoClient - -from .parsing import MODEL, MODEL_TABLE -from .protocol_constants import ( - CMD_ID, - CMD_READ, - NOTIFY_UUID, - SERVICE_UUID, - WRITE_UUID, -) - - -class BleWorker: - __slots__ = ( - "device", "_on_packet", "_on_tx", "_on_status", "_on_error", - "_on_connected", "_on_disconnected", "_stopping", "alive", - "_pending_cmd", "_loop", "_io_event", - ) - MODEL_PREFIX = b"\xdf\x05\x03\x08\x14" - - def __init__( - self, - device, - *, - on_packet=None, - on_tx=None, - on_status=None, - on_error=None, - on_connected=None, - on_disconnected=None, - ): - self.device = device - self._on_packet = on_packet - self._on_tx = on_tx - self._on_status = on_status - self._on_error = on_error - self._on_connected = on_connected - self._on_disconnected = on_disconnected - self._stopping = False - self.alive = False - self._pending_cmd: bytes | None = None - self._loop: asyncio.AbstractEventLoop | None = None - self._io_event: asyncio.Event | None = None - - self.alive = True - try: - _thread.start_new_thread(self.run, ()) - except Exception: - self.alive = False - raise - - def stop(self) -> None: - self._stopping = True - self._wake() - - def _wake(self) -> None: - loop = self._loop - io_event = self._io_event - if loop is not None and io_event is not None: - loop.call_soon_threadsafe(io_event.set) - - def set_command(self, payload: bytes) -> None: - self._pending_cmd = payload - self._wake() - - def run(self) -> None: - loop = asyncio.new_event_loop() - self._loop = loop - try: - loop.run_until_complete(self._main()) - except Exception as exc: - self._emit(self._on_error, f"Worker failed: {exc!r}") - finally: - self._loop = None - self._io_event = None - loop.close() - self.alive = False - - @staticmethod - def _emit(callback, *args) -> None: - if callback is not None: - callback(*args) - - async def _main(self) -> None: - device = self.device - address = device.address - - last_rx = 0.0 - no_data_emitted = False - disconnected = False - disconnect_notified = False - read_ready = True - io_event = asyncio.Event() - self._io_event = io_event - - def _notify_disconnect() -> None: - nonlocal disconnect_notified - if disconnect_notified: - return - disconnect_notified = True - self._emit(self._on_status, f"disconnected — {address}") - self._emit(self._on_disconnected, address) - - def on_notify(data: bytes) -> None: - nonlocal last_rx, no_data_emitted, read_ready - last_rx = time.monotonic() - no_data_emitted = False - read_ready = True - io_event.set() - - if data.startswith(self.MODEL_PREFIX): - idx = data[9] - ord("A") - if 0 <= idx < len(MODEL_TABLE): - MODEL.model_name, MODEL.device_counts = MODEL_TABLE[idx] - return - - self._emit(self._on_packet, data) - - def on_disconnect() -> None: - nonlocal disconnected - disconnected = True - self._wake() - _notify_disconnect() - - self._emit(self._on_status, f"connecting — {address}") - async with NanoClient(device, disconnected_callback=on_disconnect) as client: - await client.prime_gatt(SERVICE_UUID, [NOTIFY_UUID, WRITE_UUID]) - await client.start_notify( - NOTIFY_UUID, - on_notify, - ) - - try: - await client.write_gatt_char(WRITE_UUID, CMD_ID) - except Exception as exc: - self._emit(self._on_error, f"ID request failed: {exc!r}") - return - self._emit(self._on_status, f"connected — {address}") - self._emit(self._on_connected, address) - self._emit(self._on_tx, CMD_ID) - io_event.set() - - try: - while not self._stopping: - if disconnected: - _notify_disconnect() - break - - await io_event.wait() - io_event.clear() - if self._stopping or disconnected: - break - - if read_ready: - cmd = self._pending_cmd - self._pending_cmd = None - payload = cmd if cmd is not None else CMD_READ - - try: - await client.write_gatt_char(WRITE_UUID, payload) - except Exception as exc: - self._emit(self._on_error, f"{'Write' if cmd else 'Read'} failed: {exc!r}") - return - - if cmd is not None: - self._emit(self._on_tx, payload) - read_ready = False - - if last_rx and not no_data_emitted and (time.monotonic() - last_rx) > 2.0: - self._emit(self._on_status, f"connected — {address} — (no data)") - no_data_emitted = True - - finally: - try: - await client.stop_notify( - NOTIFY_UUID, - ) - except Exception: - pass - _notify_disconnect() diff --git a/dm40/parsing.py b/dm40/parsing.py index 36afa8d..3c240ae 100644 --- a/dm40/parsing.py +++ b/dm40/parsing.py @@ -48,40 +48,24 @@ class Measurement: "third_val", "third_unit", "overload", - "crc_ok", + "crc_str", ) - def __init__( - self, - raw="", - kind="---", - range=None, - display_unit="", - value_str="---", - norm_value=None, - vertical_pad=0.0, - decimals=2, - sec_val=None, - sec_unit="", - third_val=None, - third_unit="", - overload=False, - crc_ok=False, - ): - self.raw = raw - self.kind = kind - self.range = range - self.display_unit = display_unit - self.value_str = value_str - self.norm_value = norm_value - self.vertical_pad = vertical_pad - self.decimals = decimals - self.sec_val = sec_val - self.sec_unit = sec_unit - self.third_val = third_val - self.third_unit = third_unit - self.overload = overload - self.crc_ok = crc_ok + def __init__(self): + self.raw = "" + self.kind = "---" + self.range = "" + self.display_unit = "" + self.value_str = "---" + self.norm_value = None # type: ignore[assignment] + self.vertical_pad = 0.0 + self.decimals = 2 + self.sec_val = "" + self.sec_unit = "" + self.third_val = "" + self.third_unit = "" + self.overload = False + self.crc_str = "" MODEL_TABLE = (("DM40A", 40000), ("DM40B", 50000), ("DM40C", 60000)) @@ -89,24 +73,29 @@ def __init__( def resolve_slot_scale(slot: str, kind: str, sign_flag: int): scale_flag = sign_flag & 0xFE + if slot == "FREQ": return FREQ_SCALE_MAP[scale_flag] - factor = MODEL.device_counts / 60000.0 - if kind == "CAP" and slot == "M1": - info = CAP_SCALE_MAP[scale_flag] - elif slot in ("M1", "COMB", "DC", "AC") and (kind.startswith("V") or kind == "DIODE"): - info = ALT_SCALE_MAP[scale_flag] - elif slot in ("M1", "COMB", "DC", "AC") and kind.startswith("A"): - info = AMP_SCALE_MAP[scale_flag] - elif slot == "M1" and kind in ("RES", "RES_ONLINE", "CONT"): - info = RES_SCALE_MAP[scale_flag] + + if slot in ("M1", "DC", "AC"): + if kind.startswith("V") or kind == "DIODE": + info = ALT_SCALE_MAP[scale_flag] + elif kind.startswith("A"): + info = AMP_SCALE_MAP[scale_flag] + elif kind in ("RES", "RES_ONLINE", "CONT"): + info = RES_SCALE_MAP[scale_flag] + elif kind == "CAP": + info = CAP_SCALE_MAP[scale_flag] + else: + return None elif slot == "TC" and kind == "TEMP": info = (6000.0, "°C", 1.0, 1) elif slot == "RES" and kind == "DIODE": - return 6000.0 * factor, "Ω", 1.0, 1 + info = (6000.0, "Ω", 1.0, 1) else: return None + factor = MODEL.device_counts / 60000.0 fs_base, unit, mul, dec = info return fs_base * factor, unit, mul, dec @@ -122,23 +111,28 @@ def parse_device_status(data: bytes) -> tuple: def process_slot(slot_type: str, counts: int, sign_flag: int, kind: str): - sign = -1 if (sign_flag & 0x01) else 1 + ol = counts == 0xFFFF - if not (resolved := resolve_slot_scale(slot_type, kind, sign_flag)): - if slot_type in ("DUTY", "TF", "TI"): - val = counts * 0.1 - return f"{val:.1f}", "%" if slot_type == "DUTY" else ("°F" if slot_type == "TF" else "°C") - return "", "" + if resolved := resolve_slot_scale(slot_type, kind, sign_flag): + full_scale, disp_unit, disp_mul, decimals = resolved + if ol: + return "OL", disp_unit + sign = -1 if (sign_flag & 1) else 1 + val_disp = counts * (full_scale / MODEL.device_counts) * disp_mul * sign + return "%.*f" % (decimals, val_disp), disp_unit - full_scale, disp_unit, disp_mul, decimals = resolved - scale = full_scale / MODEL.device_counts - val_disp = (counts * scale * disp_mul) * sign - return f"{val_disp:.{decimals}f}", disp_unit + if slot_type in ("DUTY", "TF", "TI"): + val_str = "OL" if ol else "%.1f" % (counts * 0.1) + if slot_type == "DUTY": + return val_str, "%" + return val_str, "°F" if slot_type == "TF" else "°C INT" + return "", "" def parse_measurement_for_ui(data: bytes) -> Measurement: - m = Measurement(raw=data.hex(" ").upper()) - m.crc_ok = ((sum(data) & 0xFF) == 0) if data else False + m = Measurement() + m.raw = data.hex(" ").upper() + m.crc_str = "PASS" if (sum(data) & 0xFF) == 0 else "FAIL" if len(data) < 16 or not data.startswith(HEADER): return m @@ -165,15 +159,15 @@ def parse_measurement_for_ui(data: bytes) -> Measurement: if not m.overload: sign = -1 if (s0 & 0x01) else 1 - m.norm_value = sign * m1 * (fs1 / eff_counts) - m.value_str = f"{m.norm_value * mul1:.{dec1}f}" + m.norm_value = sign * m1 * (fs1 / eff_counts) # type: ignore[assignment] + m.value_str = "%.*f" % (dec1, m.norm_value * mul1) # type: ignore[operator] if not rng_name.startswith("AUTO"): m.range = f"{(fs1 * mul1):.4g}{unit1}" - if len(slots) > 1 and m2 != 0xFFFF: + if len(slots) > 1: m.sec_val, m.sec_unit = process_slot(slots[1], m2, s1, kind) - if len(slots) > 2 and m3 != 0xFFFF: + if len(slots) > 2: m.third_val, m.third_unit = process_slot(slots[2], m3, s2, kind) return m diff --git a/dm40/protocol_constants.py b/dm40/protocol_constants.py index 00ff6c1..14ecb34 100644 --- a/dm40/protocol_constants.py +++ b/dm40/protocol_constants.py @@ -1,8 +1,5 @@ """Protocol constants and command maps for DM40.""" -NOTIFY_UUID = "0000fff1-0000-1000-8000-00805f9b34fb" -WRITE_UUID = "0000fff3-0000-1000-8000-00805f9b34fb" -SERVICE_UUID = "0000fff0-0000-1000-8000-00805f9b34fb" CMD_ID = b"\xaf\x05\x03\x08\x00\x41" CMD_READ = b"\xaf\x05\x03\x09\x00\x40" HEADER = b"\xdf\x05\x03\x09" @@ -212,7 +209,6 @@ } UNIT_TO_BASE = { - "uV": 1e-6, "mV": 1e-3, "V": 1.0, "uA": 1e-6, @@ -226,7 +222,6 @@ "nF": 1e-9, "uF": 1e-6, "mF": 1e-3, - "F": 1.0, "°C": 1.0, "°F": 1.0, "%": 1.0, @@ -235,10 +230,10 @@ MODE_SLOT_MAP = { "VDC": ("M1",), "VAC": ("M1", "DUTY", "FREQ"), - "VDC+AC": ("COMB", "DC", "AC"), + "VDC+AC": ("M1", "DC", "AC"), "ADC": ("M1",), "AAC": ("M1", "DUTY", "FREQ"), - "ADC+AC": ("COMB", "DC", "AC"), + "ADC+AC": ("M1", "DC", "AC"), "RES": ("M1",), "RES_ONLINE": ("M1",), "CAP": ("M1",), diff --git a/main.py b/main.py index 66ca728..fc7bb46 100644 --- a/main.py +++ b/main.py @@ -1,12 +1,10 @@ -if '__compiled__' in globals(): # type: ignore[name-defined] +if '__compiled__' in globals(): # type: ignore[name-defined] import shims shims.install() def main() -> None: - from dm40.app import DM40App - - app = DM40App() - app.mainloop() + from shared.base_app import App + App().mainloop() if __name__ == "__main__": main() diff --git a/shared/base_app.py b/shared/base_app.py new file mode 100644 index 0000000..b0c12b0 --- /dev/null +++ b/shared/base_app.py @@ -0,0 +1,462 @@ +"""Single-window application.""" +import _thread +import time +import tkinter as tk +from tkinter import ttk + +from shared import mini_asyncio as asyncio +from shared.device_registry import guess_device_type, load_handler +from shared.nanowinbt.scanner import NanoScanner + +from GUI.controls import UIControls +from GUI.theme_manager import ThemeManager +from GUI.themed_messagebox import show_error +from GUI.widgets.autoscrollbar import AutoScrollbar +from GUI.widgets.find_popup import FindPopup +from GUI.widgets.helpers import ensure_dpi_awareness, theme_title_bar +from GUI.widgets.menubar import OwnerDrawnMenuBar +from GUI.widgets.themed_button import ThemedButton +from GUI.widgets.waveform_view import WaveformView + + +class App(tk.Tk): + + def __init__(self) -> None: + super().__init__() + ensure_dpi_awareness() + + self.title("DM40GUI") + self.minsize(916, 650) + self.wm_geometry("916x650") + + self.style = ttk.Style(self) + self._theme_manager = ThemeManager(self, self.style, self._apply_theme) + initial_theme = self._theme_manager.get_active_theme() + self.ui = UIControls(self, self.style, theme=initial_theme) + theme_title_bar(self, border_color=initial_theme.outline, caption_color=initial_theme.bg) + + self._worker = None + self._is_connected = False + self._handler = None + self._handler_type = None + + self._devices = [] + self._device_index_by_address: dict[str, int] = {} + self._scan_in_progress = False + self._scan_generation = 0 + self._scan_cancel = None + + self._reset_stats() + self._rate_count = 0 + self._rate_start = self._start_time = 0.0 + + self._build_ui() + + self.bind_all("", self._toggle_wave_pause) + self.bind_all("", self._save_wave_csv) + self.bind_all("", self._toggle_wave_record) + self.protocol("WM_DELETE_WINDOW", self._on_close) + + def _reset_stats(self) -> None: + self._stats_count = 0 + self._stats_sum = self._stats_min = self._stats_max = 0.0 + + def _push_stats(self, value: float) -> tuple[float, float, float]: + if self._stats_count == 0: + self._stats_min = self._stats_max = self._stats_sum = value + else: + if value < self._stats_min: + self._stats_min = value + if value > self._stats_max: + self._stats_max = value + self._stats_sum += value + self._stats_count += 1 + return self._stats_min, self._stats_max, self._stats_sum / self._stats_count + + def _build_ui(self) -> None: + self._menu_bar = OwnerDrawnMenuBar( + self, + menus=[ + ("File", [ + ("Save Buffer", self._save_wave_csv), + ("Record", self._toggle_wave_record), + "separator", + ("Clear", self._clear_capture_data), + "separator", + ("Exit", self._on_close), + ]), + ("Themes", []), + ], + theme_manager=self._theme_manager, + on_theme=self._apply_theme, + ) + self._menu_bar.pack(fill=tk.X) + self._menu_bar.grid_columnconfigure(2, weight=1) + + self._menubar_pre = tk.Frame(self._menu_bar) + self._menubar_pre.grid(row=0, column=3, sticky="e") + + self._menubar_post = tk.Frame(self._menu_bar) + self._menubar_post.grid(row=0, column=5, sticky="e") + + self._status_var = tk.StringVar(value="Disconnected") + + root = tk.Frame(self, padx=12, pady=12) + root.pack(fill=tk.BOTH, expand=True) + root.columnconfigure(0, weight=1) + + mid = tk.Frame(root) + mid.grid(row=0, column=0, sticky="nsew") + mid.columnconfigure(0, weight=1) + root.rowconfigure(0, weight=3) + + self._reading_host = tk.Frame(mid) + self._reading_host.pack(fill=tk.X) + tk.Label(self._reading_host, text="Connect to a compatible device", + font=("Segoe UI", 16), pady=40).pack() + + self._stats_var = tk.StringVar(value="") + tk.Label(mid, textvariable=self._stats_var, font=("Consolas", 10), + anchor="w").pack(fill=tk.X, side=tk.BOTTOM) + + self._wave_view = WaveformView(mid, colors=self.ui.theme, capacity=600) + self._wave_view.pack(fill=tk.BOTH, expand=True, pady=(10, 0)) + + raw_frame = tk.Frame(root) + raw_frame.grid(row=1, column=0, sticky="nsew") + raw_frame.columnconfigure(0, weight=1) + raw_frame.rowconfigure(1, weight=1) + root.rowconfigure(1, weight=1) + + tk.Label(raw_frame, text="Raw packets:").grid(row=0, column=0, sticky="w") + + self._raw_text = tk.Text( + raw_frame, wrap="none", height=10, relief="flat", + highlightthickness=2, undo=False, maxundo=0, autoseparators=False, + ) + self._raw_text.grid(row=1, column=0, sticky="nsew", pady=(6, 0)) + + yscroll = AutoScrollbar( + raw_frame, orient="vertical", command=self._raw_text.yview, + style="Arrowless.Vertical.TScrollbar", + ) + yscroll.grid(row=1, column=1, sticky="ns", pady=(6, 0)) + self._raw_text.configure(yscrollcommand=yscroll.set, state="disabled") + + device_panel = tk.Frame(raw_frame) + device_panel.grid(row=0, column=2, rowspan=2, sticky="nsw", padx=(12, 0)) + device_panel.rowconfigure(1, weight=1) + device_panel.columnconfigure(0, weight=0) + device_panel.columnconfigure(1, weight=1) + device_panel.columnconfigure(2, weight=0) + + tk.Label(device_panel, text="Devices:").grid(row=0, column=0, sticky="w") + tk.Label(device_panel, textvariable=self._status_var).grid( + row=0, column=1, columnspan=2, sticky="e" + ) + + self._device_listbox = tk.Listbox( + device_panel, height=10, width=58, exportselection=False, + activestyle="none", highlightthickness=2, relief="flat", + ) + + def _on_click(event): + index = self._device_listbox.nearest(event.y) + bbox = self._device_listbox.bbox(index) + if bbox is None or event.y > bbox[1] + bbox[3]: + return "break" + + self._device_listbox.bind("", _on_click) + self._device_listbox.grid(row=1, column=0, columnspan=3, sticky="nsew", pady=(6, 6)) + + ThemedButton(device_panel, text="Scan", command=self.scan_devices).grid( + row=2, column=0, sticky="w" + ) + ThemedButton(device_panel, text="Connect", command=self.connect).grid( + row=2, column=1, sticky="e", padx=(0, 6) + ) + ThemedButton(device_panel, text="Disconnect", command=self.disconnect).grid( + row=2, column=2, sticky="e" + ) + + self._find = FindPopup( + raw_frame, self._raw_text, self.ui.theme, + grid_opts={"row": 1, "column": 0, "sticky": "ne", "padx": 6, "pady": (10, 0)}, + ) + + self._control_bar = tk.Frame(root) + self._control_bar.grid(row=2, column=0, sticky="w", pady=(8, 0)) + root.rowconfigure(2, weight=0) + + def _setup_handler(self, dtype: str) -> None: + if dtype == self._handler_type: + return + if self._handler: + self._handler.teardown() + for frame in (self._reading_host, self._menubar_pre, self._menubar_post, self._control_bar): + for w in frame.winfo_children(): + w.destroy() + self._handler_type = dtype + self._handler = load_handler(dtype, self) + self._handler.build_reading_area(self._reading_host) + self._handler.build_menubar_labels(self._menubar_pre, self._menubar_post) + self._handler.build_control_bar(self._control_bar) + + def _apply_theme(self, theme) -> None: + self.ui.use_theme(theme) + theme_title_bar(self, border_color=theme.outline, caption_color=theme.bg) + self._wave_view.set_colors(self.ui.theme) + self._find.set_tag_colors(self.ui.theme) + + _CSV_DIR = __compiled__.containing_dir if '__compiled__' in globals() else __file__.rsplit('\\', 2)[0] # type: ignore[name-defined] + + def _csv_path(self, prefix: str) -> str: + return f"{self._CSV_DIR}\\{prefix}_{time.strftime('%Y%m%d_%H%M%S')}.csv" + + def _toggle_wave_pause(self, _event=None) -> None: + self._wave_view.toggle_pause() + self._refresh_status_bar() + + def _save_wave_csv(self, _event=None) -> None: + prefix = self._handler.csv_prefix if self._handler else "capture" + self._wave_view.save_buffer_csv(self._csv_path(prefix)) + + def _toggle_wave_record(self, _event=None) -> None: + prefix = self._handler.csv_prefix if self._handler else "capture" + if self._wave_view.recording: + self._wave_view.stop_recording() + else: + self._wave_view.toggle_recording(self._csv_path(f"{prefix}_rec")) + self._refresh_status_bar() + + def _clear_capture_data(self) -> None: + self._wave_view.clear() + self._raw_text.configure(state="normal") + self._raw_text.delete("1.0", "end") + self._raw_text.configure(state="disabled") + self._reset_stats() + self._stats_var.set("") + if self._handler: + self._handler.clear_capture() + self._refresh_status_bar() + + def send_command(self, cmd_prefix: bytes) -> None: + if not self._worker or not self._worker.alive: + show_error(self, "Command", "Connect to a device before sending commands.", + theme=(self.ui.theme.bg, self.ui.theme.outline)) + return + self._worker.set_command(b"%b%c" % (cmd_prefix, (-sum(cmd_prefix)) & 0xFF)) + + def _radio_off(self, title: str) -> bool: + if NanoScanner.radio_state() != "off": + return False + msg = "Bluetooth radio is OFF. Turn Bluetooth on and try again." + self._status_var.set(msg) + show_error(self, title, msg, theme=(self.ui.theme.bg, self.ui.theme.outline)) + return True + + def scan_devices(self) -> None: + if self._scan_in_progress or self._radio_off("Scan"): + return + self._scan_generation += 1 + scan_id = self._scan_generation + self._scan_in_progress = True + self._scan_cancel = None + self._devices = [] + self._device_index_by_address.clear() + self._device_listbox.delete(0, tk.END) + self._status_var.set("Scanning …") + _thread.start_new_thread(self._scan_worker, (scan_id,)) + + def _scan_worker(self, scan_id: int) -> None: + def on_device(device) -> None: + self.after(0, self._scan_add_device, scan_id, device) + + def register_cancel(cancel_scan) -> None: + if scan_id == self._scan_generation: + self._scan_cancel = cancel_scan + else: + cancel_scan() + + try: + devices = asyncio.run( + NanoScanner.discover( + scanning_mode="active", + on_device=on_device, + timeout=3.0, + on_cancel_register=register_cancel, + require_radio_check=False, + ) + ) + except Exception as exc: + self.after(0, self._scan_done, scan_id, [], exc) + return + self.after(0, self._scan_done, scan_id, devices or [], None) + + def _scan_add_device(self, scan_id: int, device) -> None: + if scan_id != self._scan_generation or not device.address: + return + addr, name = device.address, device.name or "Unknown" + idx = self._device_index_by_address.get(addr) + if idx is not None: + self._devices[idx].name = device.name + label = f"{name} ({addr})" + if name != "Unknown" and self._device_listbox.get(idx) != label: + self._device_listbox.delete(idx) + self._device_listbox.insert(idx, label) + return + self._device_index_by_address[addr] = len(self._devices) + self._devices.append(device) + self._device_listbox.insert(tk.END, f"{name} ({addr})") + + def _scan_done(self, scan_id: int, devices: list, exc) -> None: + if scan_id != self._scan_generation: + return + self._scan_in_progress = False + self._scan_cancel = None + if exc: + self._status_var.set("Scan failed") + show_error(self, "Scan", f"BLE scan failed: {exc!r}", + theme=(self.ui.theme.bg, self.ui.theme.outline)) + return + for d in devices: + self._scan_add_device(scan_id, d) + n = len(self._devices) + self._status_var.set(f"Found {n} device{'s' if n != 1 else ''}") + + def connect(self) -> None: + if self._scan_in_progress: + self._scan_generation += 1 + self._scan_in_progress = False + cancel, self._scan_cancel = self._scan_cancel, None + if cancel: + try: cancel() + except Exception: pass + if self._radio_off("Connect"): + return + sel = self._device_listbox.curselection() + device = self._devices[sel[0]] if sel else None + if not device: + show_error(self, "Connect", "Select a device from the list.", + theme=(self.ui.theme.bg, self.ui.theme.outline)) + return + if self._worker and self._worker.alive: + return + self._worker = None + + dtype = guess_device_type(device.name or "") + if dtype: + self._start_connection(device, dtype) + else: + self._status_var.set("Identifying device …") + _thread.start_new_thread(self._probe_and_connect, (device,)) + + def _probe_and_connect(self, device) -> None: + from shared.ble_worker import probe_device_type + dtype = probe_device_type(device) + def _finish(): + if self._worker and self._worker.alive: + return + if dtype: + self._start_connection(device, dtype) + else: + self._status_var.set("Unrecognized device") + show_error(self, "Connect", + f"'{device.name or device.address}' is not a recognized device.", + theme=(self.ui.theme.bg, self.ui.theme.outline)) + self.after(0, _finish) + + def _start_connection(self, device, dtype: str) -> None: + self._setup_handler(dtype) + handler = self._handler + assert handler is not None + handler.pre_connect_reset() + self._wave_view.clear() + self._reset_stats() + self._stats_var.set("") + self._status_var.set(f"Connecting to {device.name or device.address} …") + + self._alive = True + _alive = self.__dict__ + def _ui(method): + def _dispatch(*a): + self.after(0, _guarded, method, *a) + return _dispatch + def _guarded(method, *a): + if '_alive' in _alive: + method(*a) + self._refresh_status_bar() + + self._worker = handler.create_worker( + device, + on_packet=_ui(handler.on_packet), + on_tx=_ui(lambda p: self._append_raw_text(f"TX {p.hex(' ').upper()}\n")), + on_status=_ui(self._status_var.set), + on_error=_ui(self._on_worker_error), + on_connected=_ui(self._on_worker_connected), + on_disconnected=_ui(self._on_worker_disconnected), + ) + + def disconnect(self) -> None: + self.__dict__.pop('_alive', None) + if self._worker: + self._worker.stop() + self._worker = None + self._status_var.set("Disconnected") + self._is_connected = False + self._rate_count = 0 + if self._handler: + self._handler.set_control_state(False) + self._refresh_status_bar() + + def _on_close(self) -> None: + self.disconnect() + self.destroy() + + def _on_worker_connected(self, _address: str) -> None: + self._is_connected = True + self._rate_count = 0 + self._start_time = self._rate_start = time.monotonic() + self._handler.set_control_state(True) # type: ignore[union-attr] + self._handler.on_connected() # type: ignore[union-attr] + + def _on_worker_disconnected(self, _address: str) -> None: + self._is_connected = False + self._rate_count = 0 + self._status_var.set("Disconnected") + self._worker = None + self._handler.set_control_state(False) # type: ignore[union-attr] + + def _on_worker_error(self, message: str) -> None: + self._status_var.set(message) + show_error(self, self._handler.title, message, theme=(self.ui.theme.bg, self.ui.theme.outline)) # type: ignore[union-attr] + self._handler.set_control_state(False) # type: ignore[union-attr] + + def _append_raw_text(self, text: str) -> None: + _, bottom = self._raw_text.yview() + at_bottom = bottom >= 1.0 + self._raw_text.configure(state="normal") + self._raw_text.insert("end", text) + if at_bottom: + self._raw_text.see("end") + self._raw_text.configure(state="disabled") + + def _refresh_status_bar(self) -> None: + now = time.monotonic() + title_base = self._handler.title if self._handler_type else "DM40GUI" # type: ignore[union-attr] + if self._is_connected: + dt = now - self._rate_start + rate = self._rate_count / dt if dt > 0 else 0.0 + if dt >= 2.0: + self._rate_count = 0 + self._rate_start = now + title = f"{title_base} - {rate:.1f} samples/s" + else: + title = title_base + if self._wave_view.paused: + title += " \u23F8 PAUSED" + if self._wave_view.recording: + title += " \u23FA REC" + self.title(title) + if self._handler_type: + self._handler.refresh_status(now) # type: ignore[union-attr] diff --git a/shared/ble_worker.py b/shared/ble_worker.py new file mode 100644 index 0000000..defb579 --- /dev/null +++ b/shared/ble_worker.py @@ -0,0 +1,235 @@ +"""BLE transport worker.""" + +import _thread +import time + +from shared import mini_asyncio as asyncio +from shared.device_registry import FAMILY_MAP +from shared.nanowinbt.client import NanoClient + +_SERVICE_UUID = "0000fff0-0000-1000-8000-00805f9b34fb" +_NOTIFY_UUID = "0000fff1-0000-1000-8000-00805f9b34fb" +_WRITE_UUID = "0000fff3-0000-1000-8000-00805f9b34fb" + +_CMD_DISCOVERY = b"\xaf\xff\xff\x00\x00\x53" +_DISCOVERY_HEADER = b"\xdf\xff\xff\x00" + + +def probe_device_type(device, timeout: float = 6.0) -> str | None: + """Connect, send discovery command, return a registered device type or None. + Blocking; call from a background thread.""" + result = [None] + try: + asyncio.run(_probe(device, timeout, result)) + except Exception: + pass + return result[0] + + +async def _probe(device, timeout, result): + loop = asyncio.get_running_loop() + got = asyncio.Event() + + def on_notify(data: bytes): + if data.startswith(_DISCOVERY_HEADER) and len(data) >= 7: + family = FAMILY_MAP.get(data[5:7]) + if family: + result[0] = family + loop.call_soon_threadsafe(got.set) + + async with NanoClient(device, timeout=timeout) as client: + await client.prime_gatt(_SERVICE_UUID, [_NOTIFY_UUID, _WRITE_UUID], 8) + await client.start_notify(_NOTIFY_UUID, on_notify) + await client.write_gatt_char(_WRITE_UUID, _CMD_DISCOVERY) + + deadline_handle = loop.call_later(3.0, got.set) + await got.wait() + deadline_handle.cancel() + + try: + await client.stop_notify(_NOTIFY_UUID) + except Exception: + pass + + +class BleWorker: + __slots__ = ( + "device", "_on_packet", "_on_tx", "_on_status", "_on_error", + "_on_connected", "_on_disconnected", "_stopping", "alive", + "_pending_cmd", "_loop", "_io_event", + "_poll_cmd", "_init_cmd", "_notify_hook", "_write_buf_size", + "_cmd_requires_notify", + ) + + def __init__( + self, + device, + *, + poll_cmd: bytes, + init_cmd: bytes | None = None, + notify_hook=None, + write_buf_size: int = 20, + cmd_requires_notify: bool = True, + on_packet=None, + on_tx=None, + on_status=None, + on_error=None, + on_connected=None, + on_disconnected=None, + ): + self.device = device + self._poll_cmd = poll_cmd + self._init_cmd = init_cmd + self._notify_hook = notify_hook + self._write_buf_size = write_buf_size + self._cmd_requires_notify = cmd_requires_notify + self._on_packet = on_packet + self._on_tx = on_tx + self._on_status = on_status + self._on_error = on_error + self._on_connected = on_connected + self._on_disconnected = on_disconnected + self._stopping = False + self.alive = True + self._pending_cmd: bytes | None = None + self._loop = None + self._io_event = None + _thread.start_new_thread(self.run, ()) + + def stop(self) -> None: + self._stopping = True + self._wake() + + def _wake(self) -> None: + loop = self._loop + io_event = self._io_event + if loop is not None and io_event is not None: + loop.call_soon_threadsafe(io_event.set) + + def set_command(self, payload: bytes) -> None: + self._pending_cmd = payload + self._wake() + + def run(self) -> None: + loop = asyncio.new_event_loop() + self._loop = loop + try: + loop.run_until_complete(self._main()) + except Exception as exc: + on_error = self._on_error + if on_error: + on_error(f"Worker failed: {exc!r}") + finally: + self._loop = None + self._io_event = None + loop.close() + self.alive = False + + async def _main(self) -> None: + device = self.device + address = device.address + on_packet = self._on_packet + on_tx = self._on_tx + on_status = self._on_status + on_error = self._on_error + on_connected = self._on_connected + on_disconn = self._on_disconnected + poll_cmd = self._poll_cmd + init_cmd = self._init_cmd + notify_hook = self._notify_hook + cmd_requires_notify = self._cmd_requires_notify + + last_rx = 0.0 + no_data_emitted = False + disconnected = False + disconnect_notified = False + read_ready = True + io_event = asyncio.Event() + self._io_event = io_event + + def _notify_disconnect() -> None: + nonlocal disconnect_notified + if disconnect_notified: + return + disconnect_notified = True + if on_status: + on_status(f"disconnected: {address}") + if on_disconn: + on_disconn(address) + + def on_notify(data: bytes) -> None: + nonlocal last_rx, no_data_emitted, read_ready + last_rx = time.monotonic() + no_data_emitted = False + read_ready = True + io_event.set() + if (notify_hook is None or notify_hook(data)) and on_packet: + on_packet(data) + + def on_disconnect() -> None: + nonlocal disconnected + disconnected = True + self._wake() + _notify_disconnect() + + if on_status: + on_status(f"connecting: {address}") + async with NanoClient(device, disconnected_callback=on_disconnect) as client: + await client.prime_gatt(_SERVICE_UUID, [_NOTIFY_UUID, _WRITE_UUID], self._write_buf_size) + await client.start_notify(_NOTIFY_UUID, on_notify) + + if init_cmd is not None: + try: + await client.write_gatt_char(_WRITE_UUID, init_cmd) + except Exception as exc: + if on_error: + on_error(f"Init failed: {exc!r}") + return + if on_tx: + on_tx(init_cmd) + + if on_status: + on_status(f"connected: {address}") + if on_connected: + on_connected(address) + io_event.set() + + try: + while not self._stopping: + if disconnected: + break + + await io_event.wait() + io_event.clear() + if self._stopping or disconnected: + break + + if read_ready: + cmd = self._pending_cmd + self._pending_cmd = None + payload = poll_cmd if cmd is None else cmd + try: + await client.write_gatt_char(_WRITE_UUID, payload) + except Exception as exc: + if on_error: + on_error(f"{'Write' if cmd else 'Poll'} failed: {exc!r}") + return + if cmd is not None and on_tx: + on_tx(payload) + if cmd is None or cmd_requires_notify: + read_ready = False + else: + io_event.set() + + if last_rx and not no_data_emitted and (time.monotonic() - last_rx) > 2.0: + if on_status: + on_status(f"connected: {address} (no data)") + no_data_emitted = True + + finally: + if not disconnected: + try: + await client.stop_notify(_NOTIFY_UUID) + except Exception: + pass + _notify_disconnect() diff --git a/shared/device_registry.py b/shared/device_registry.py new file mode 100644 index 0000000..1d03c3d --- /dev/null +++ b/shared/device_registry.py @@ -0,0 +1,35 @@ +"""Device registry: one place to declare a new handler. + +Each entry maps a device-type string to: + (handler_module, handler_class, name_prefix, discovery_family_bytes) + +- `handler_module` / `handler_class` are imported lazily so unused handlers + stay out of the frozen binary's startup path. +- `name_prefix` is matched case-insensitively against the BLE advertised name. +- `discovery_family_bytes` are bytes 5-6 of the reply to the probe command + sent by `shared.ble_worker.probe_device_type`. +""" + +DEVICE_REGISTRY: dict[str, tuple[str, str, str, bytes]] = { + "DM40": ("dm40.app", "DM40Handler", "DM40", b"\x05\x03"), + "EL15": ("el15.app", "EL15Handler", "EL15", b"\x07\x03"), +} + + +def guess_device_type(name: str) -> str | None: + upper = name.upper() + for dtype, (_, _, prefix, _) in DEVICE_REGISTRY.items(): + if upper.startswith(prefix): + return dtype + return None + + +def load_handler(dtype: str, app): + module_name, class_name, _, _ = DEVICE_REGISTRY[dtype] + module = __import__(module_name, fromlist=(class_name,)) + return getattr(module, class_name)(app) + + +FAMILY_MAP: dict[bytes, str] = { + family: dtype for dtype, (_, _, _, family) in DEVICE_REGISTRY.items() +} diff --git a/dm40/mini_asyncio.py b/shared/mini_asyncio.py similarity index 95% rename from dm40/mini_asyncio.py rename to shared/mini_asyncio.py index 711f26d..aab2929 100644 --- a/dm40/mini_asyncio.py +++ b/shared/mini_asyncio.py @@ -5,6 +5,7 @@ _tls = _thread._local() +_tls.running_loop = None class TimerHandle: @@ -194,13 +195,10 @@ def _run_once(self) -> None: callback(*args) return - sleep_for = 0.001 if self._timers: - sleep_for = self._timers[0][0] - now - if sleep_for < 0.0: - sleep_for = 0.0 - elif sleep_for > 0.01: - sleep_for = 0.01 + sleep_for = min(max(self._timers[0][0] - now, 0.0), 0.01) + else: + sleep_for = 0.001 time.sleep(sleep_for) @@ -209,7 +207,7 @@ def new_event_loop(): def get_running_loop(): - loop = getattr(_tls, "running_loop", None) + loop = _tls.running_loop if loop is None: raise RuntimeError("no running event loop") return loop diff --git a/dm40/nanowinbt/client.py b/shared/nanowinbt/client.py similarity index 81% rename from dm40/nanowinbt/client.py rename to shared/nanowinbt/client.py index 7b5f0ee..722df61 100644 --- a/dm40/nanowinbt/client.py +++ b/shared/nanowinbt/client.py @@ -4,14 +4,17 @@ from . import ctypes_winrt as w from .ctypes_com import RoSession from .scanner import _await_ptr -from dm40.types import NanoBLEDevice + + +_GATT_STATUS_NAMES = ("Success", "Unreachable", "ProtocolError", "AccessDenied") + class NanoClientError(RuntimeError): pass class NanoClient: - def __init__(self, connect_target: NanoBLEDevice, disconnected_callback=None, *, timeout: float = 30.0): + def __init__(self, connect_target, disconnected_callback=None, *, timeout: float = 30.0): self._target = connect_target self._disconnected_callback = disconnected_callback self._timeout = timeout @@ -23,8 +26,8 @@ def __init__(self, connect_target: NanoBLEDevice, disconnected_callback=None, *, self._status_token = None self._status_delegate = None self._write_buffer: w.ComPtr | None = None + self._write_buffer_ptr = None self._write_buffer_data_ptr = None - self._write_buffer_capacity = 0 async def __aenter__(self): await self.connect() @@ -64,7 +67,7 @@ async def connect(self) -> None: statics.release() if not device_ptr: - raise NanoClientError(f"Device not found for address: {bt_addr:#x}") + raise NanoClientError("Device not found for address: %#x" % bt_addr) self._device = w.ComPtr(device_ptr) w.btle_device6_request_throughput_params(device_ptr) self._register_disconnect_handler() @@ -73,8 +76,7 @@ async def disconnect(self) -> None: self._unregister_disconnect_handler() self._release_all() - async def prime_gatt(self, service_uuid: str, char_uuids: list[str]) -> None: - """Discover service and resolve all characteristics in one async call.""" + async def prime_gatt(self, service_uuid: str, char_uuids: list[str], write_buffer_size: int) -> None: device = self._device if device is None: raise NanoClientError("Not connected") @@ -88,21 +90,34 @@ async def prime_gatt(self, service_uuid: str, char_uuids: list[str]) -> None: try: device3 = device.query_interface(w.IID_BLUETOOTH_LE_DEVICE3); ptrs.append(device3) services_result_ptr = await _await_ptr( - w.btle_device3_get_gatt_services_for_uuid_async(device3.ptr, target_service), + w.btle_device3_get_gatt_services_for_uuid_uncached_async(device3.ptr, target_service), w.IID_ASYNC_COMPLETED_HANDLER_GATT_DEVICE_SERVICES_RESULT, - 3.5, + 5.0, "client.prime.services", ) services_result = w.ComPtr(services_result_ptr); ptrs.append(services_result) - if w.gatt_services_result_get_status(services_result.ptr) != 0: - raise NanoClientError("GATT service query failed") + svc_status = w.gatt_services_result_get_status(services_result.ptr) + if svc_status != 0: + conn_status = w.btle_device_get_connection_status(device.ptr) + status_name = ( + _GATT_STATUS_NAMES[svc_status] + if 0 <= svc_status < len(_GATT_STATUS_NAMES) + else "Unknown" + ) + raise NanoClientError( + "GATT service query failed: " + f"status={svc_status} ({status_name}), " + f"connection={'Connected' if conn_status == 1 else 'Disconnected'}, " + f"service_uuid={service_uuid}, " + f"missing={missing}, " + f"bt_address={self._target.bluetooth_address:#x}" + ) services_view = w.ComPtr(w.gatt_services_result_get_services(services_result.ptr)); ptrs.append(services_view) if w.vector_view_get_size(services_view.ptr) == 0: raise NanoClientError(f"Service not found: {service_uuid}") service = w.ComPtr(w.vector_view_get_at(services_view.ptr, 0)); ptrs.append(service) service3 = service.query_interface(w.IID_GATT_DEVICE_SERVICE3); ptrs.append(service3) - # Single call to get ALL characteristics, then match by UUID in Python chars_result_ptr = await _await_ptr( w.gatt_service3_get_characteristics_async(service3.ptr), w.IID_ASYNC_COMPLETED_HANDLER_GATT_CHARACTERISTICS_RESULT, @@ -114,7 +129,7 @@ async def prime_gatt(self, service_uuid: str, char_uuids: list[str]) -> None: raise NanoClientError("Characteristic query failed") chars_view = w.ComPtr(w.gatt_characteristics_result_get_characteristics(chars_result.ptr)); ptrs.append(chars_view) count = w.vector_view_get_size(chars_view.ptr) - targets = {bytes(w._guid(u)): u for u in missing} + targets = {w._bguid(u): u for u in missing} for i in range(count): cp = w.vector_view_get_at(chars_view.ptr, i) matched = targets.pop(bytes(w.gatt_characteristic_get_uuid(cp)), None) @@ -128,19 +143,18 @@ async def prime_gatt(self, service_uuid: str, char_uuids: list[str]) -> None: for p in reversed(ptrs): p.release() - # Pre-create write buffer (avoids RoGetActivationFactory on first write) - self._get_or_grow_write_buffer(20) + self._write_buffer = w.ComPtr(w.buffer_factory_create(write_buffer_size)) + self._write_buffer_ptr = self._write_buffer.ptr + self._write_buffer_data_ptr = w.buffer_get_data_ptr(self._write_buffer_ptr) async def write_gatt_char(self, char_uuid: str, data) -> None: char = self._require_char(char_uuid) - payload = data if isinstance(data, bytes) else bytes(data) - buf = self._get_or_grow_write_buffer(len(payload)) - ctypes.memmove(self._write_buffer_data_ptr, payload, len(payload)) # type: ignore[arg-type] - w.buffer_set_length(buf.ptr, len(payload)) + ctypes.memmove(self._write_buffer_data_ptr, data, len(data)) # type: ignore[arg-type] + w.buffer_set_length(self._write_buffer_ptr, len(data)) await _await_ptr( w.gatt_characteristic_write_value_with_option_async( char.ptr, - buf.ptr, + self._write_buffer_ptr, w.GATT_WRITE_OPTION_WITHOUT_RESPONSE, ), w.IID_ASYNC_COMPLETED_HANDLER_GATT_COMM_STATUS, @@ -149,21 +163,6 @@ async def write_gatt_char(self, char_uuid: str, data) -> None: get_results=False, ) - def _get_or_grow_write_buffer(self, needed: int) -> w.ComPtr: - current = self._write_buffer - if current is not None and self._write_buffer_capacity >= needed: - return current - if current is not None: - try: - current.release() - except Exception: - pass - capacity = needed if needed > 0 else 1 - self._write_buffer = w.ComPtr(w.buffer_factory_create(capacity)) - self._write_buffer_capacity = capacity - self._write_buffer_data_ptr = w.buffer_get_data_ptr(self._write_buffer.ptr) - return self._write_buffer - async def start_notify(self, char_uuid: str, callback) -> None: char = self._require_char(char_uuid) if char_uuid in self._notify_tokens: @@ -204,17 +203,16 @@ async def stop_notify(self, char_uuid: str) -> None: if token_entry is None: return - char = token_entry[0] await _await_ptr( - w.gatt_characteristic_write_cccd_async(char.ptr, w.GATT_CCCD_NONE), + w.gatt_characteristic_write_cccd_async(token_entry[0].ptr, w.GATT_CCCD_NONE), w.IID_ASYNC_COMPLETED_HANDLER_GATT_COMM_STATUS, self._timeout, "client.notify.stop", get_results=False, ) - self._notify_tokens.pop(char_uuid, None) - w.gatt_characteristic_remove_value_changed(char.ptr, token_entry[1]) + del self._notify_tokens[char_uuid] + w.gatt_characteristic_remove_value_changed(token_entry[0].ptr, token_entry[1]) def _register_disconnect_handler(self) -> None: if self._device is None: @@ -248,8 +246,8 @@ def _release_all(self) -> None: try: self._write_buffer.release() except Exception: pass self._write_buffer = None + self._write_buffer_ptr = None self._write_buffer_data_ptr = None - self._write_buffer_capacity = 0 for char, token, _delegate in self._notify_tokens.values(): try: w.gatt_characteristic_remove_value_changed(char.ptr, token) diff --git a/dm40/nanowinbt/ctypes_com.py b/shared/nanowinbt/ctypes_com.py similarity index 100% rename from dm40/nanowinbt/ctypes_com.py rename to shared/nanowinbt/ctypes_com.py diff --git a/dm40/nanowinbt/ctypes_winrt.py b/shared/nanowinbt/ctypes_winrt.py similarity index 93% rename from dm40/nanowinbt/ctypes_winrt.py rename to shared/nanowinbt/ctypes_winrt.py index 1e540ff..c0995d1 100644 --- a/dm40/nanowinbt/ctypes_winrt.py +++ b/shared/nanowinbt/ctypes_winrt.py @@ -35,20 +35,18 @@ class WinRTError(RuntimeError): _PUINT32 = ctypes.POINTER(ctypes.c_uint32) _PUINT64 = ctypes.POINTER(ctypes.c_uint64) -def _guid(s: str, - _r=str.replace, _h=bytearray.fromhex, _f=GUID.from_buffer_copy, -): +def _bguid(s: str, _r=str.replace, _h=bytearray.fromhex): b = _h(_r(s, "-", "")) b[0:4] = b[3::-1] b[4:6] = b[5:3:-1] b[6:8] = b[7:5:-1] - return _f(b) + return bytes(b) + +def _guid(s: str, _f=GUID.from_buffer_copy): + return _f(_bguid(s)) IID_IASYNC_INFO = _guid("00000036-0000-0000-C000-000000000046") -IID_IAGILE_OBJECT = _guid("94EA2B94-E9CC-49E0-C0FF-EE64CA8F5B90") -IID_IUNKNOWN = _guid("00000000-0000-0000-C000-000000000046") -# Typed async completion handler IIDs from WinRT metadata (System.Runtime.WindowsRuntime). IID_ASYNC_COMPLETED_HANDLER_BLUETOOTH_LE_DEVICE = _guid( "9156B79F-C54A-5277-8F8B-D2CC43C7E004" ) @@ -62,7 +60,6 @@ def _guid(s: str, "2154117A-978D-59DB-99CF-6B690CB3389B" ) -# Interface IIDs gathered from runtime introspection and WinSDK 22621 headers. IID_BLUETOOTH_LE_ADVERTISEMENT_WATCHER = _guid( "A6AC336F-F3D3-4297-8D6C-C81EA6623F40" ) @@ -74,7 +71,6 @@ def _guid(s: str, IID_IBUFFER_FACTORY = _guid("71AF914D-C10F-484B-BC50-14BC623B3A27") IID_IBUFFER_BYTE_ACCESS = _guid("905A0FEF-BC53-11DF-8C49-001E4FC686DA") -# Delegate specialization IIDs from Windows.Devices.Bluetooth.Advertisement.h. IID_TYPED_EVENT_HANDLER_WATCHER_RECEIVED = _guid( "90EB4ECA-D465-5EA0-A61C-033C8C5ECEF2" ) @@ -95,11 +91,10 @@ def _guid(s: str, BLUETOOTH_CONNECTION_STATUS_CONNECTED = 1 -_IUNKNOWN_BYTES = bytes(IID_IUNKNOWN) -_IAGILE_BYTES = bytes(IID_IAGILE_OBJECT) +_IUNKNOWN_BYTES = _bguid("00000000-0000-0000-C000-000000000046") +_IAGILE_BYTES = _bguid("94EA2B94-E9CC-49E0-C0FF-EE64CA8F5B90") -# Reusable WINFUNCTYPE prototypes for COM vtable fields and delegate construction. _QI_FUNC = ctypes.WINFUNCTYPE( _c_long, _c_void_p, @@ -166,7 +161,6 @@ def __exit__(self, exc_type, exc, tb): def _vtbl_invoke(this_ptr: ctypes.c_void_p | int, index, restype, argtypes, *args): - # WINFUNCTYPE returns python int pointer instead of c_void_p addr = getattr(this_ptr, 'value', this_ptr) return _get_vtbl_fn_type(restype, argtypes)( _voidp_at(_voidp_at(addr).value + index * _SZ_VOIDP).value # type: ignore[operator] @@ -206,7 +200,6 @@ def _vtbl_invoke(this_ptr: ctypes.c_void_p | int, index, restype, argtypes, *arg _ro_activate_instance.argtypes = [_c_void_p, _PPVOID] _ro_activate_instance.restype = _c_long -# Cache function prototypes so _vtbl_invoke does not rebuild WINFUNCTYPE objects. _VTBL_FN_TYPE_CACHE: dict = {} @@ -219,7 +212,6 @@ def _get_vtbl_fn_type(restype: type, argtypes: tuple[type, ...]): return fn_type -# Common argtype tuples for _vtbl_invoke call sites. _ARG_PGUID_PPVOID = (_PGUID, _PPVOID) _ARG_OUT_INT = (_PINT,) _ARG_OUT_INT16 = (_PINT16,) @@ -231,7 +223,7 @@ def _get_vtbl_fn_type(restype: type, argtypes: tuple[type, ...]): _ARG_UINT32_OUT_VOIDP = (_c_uint32, _PPVOID) _ARG_UINT64_OUT_VOIDP = (_c_uint64, _PPVOID) _ARG_CINT_OUT_VOIDP = (_c_int, _PPVOID) -_ARG_GUID_OUT_VOIDP = (GUID, _PPVOID) +_ARG_GUID_CINT_OUT_VOIDP = (GUID, _c_int, _PPVOID) _ARG_VOIDP_OUT_VOIDP = (_c_void_p, _PPVOID) _ARG_VOIDP_CINT_OUT_VOIDP = (_c_void_p, _c_int, _PPVOID) _ARG_EVENT_TOKEN = (EventRegistrationToken,) @@ -370,7 +362,6 @@ def btle_statics_from_bluetooth_address_async( # IBluetoothLEDeviceStatics::Fro def btle_device6_request_throughput_params(device_ptr: ctypes.c_void_p) -> None: """Request ThroughputOptimized connection parameters (interval 12 = 15ms).""" - # QI Device6 first — absent on Win10 d6 = _c_void_p() if _vtbl_invoke(device_ptr, 0, _c_long, _ARG_PGUID_PPVOID, @@ -387,8 +378,7 @@ def btle_device6_request_throughput_params(device_ptr: ctypes.c_void_p) -> None: _vtbl_invoke(d6, 8, _c_long, # RequestPreferredConnectionParameters [8] _ARG_VOIDP_OUT_VOIDP, preset, _byref(req)) - if req.value: - release_ptr(req) + release_ptr(req) release_ptr(preset) except Exception: pass @@ -396,10 +386,10 @@ def btle_device6_request_throughput_params(device_ptr: ctypes.c_void_p) -> None: release_ptr(d6) -def btle_device3_get_gatt_services_for_uuid_async(ptr, service_uuid): # IBluetoothLEDevice3::GetGattServicesForUuidAsync [10] +def btle_device3_get_gatt_services_for_uuid_uncached_async(ptr, service_uuid): # IBluetoothLEDevice3::GetGattServicesForUuidAsync(serviceUuid, cacheMode) [11] op = _c_void_p() - hr = _vtbl_invoke(ptr, 10, _c_long, _ARG_GUID_OUT_VOIDP, service_uuid, _byref(op)) - _check_hresult(hr, "BTLEDevice3.GetGattServicesForUuidAsync") + hr = _vtbl_invoke(ptr, 11, _c_long, _ARG_GUID_CINT_OUT_VOIDP, service_uuid, _c_int(1), _byref(op)) + _check_hresult(hr, "BTLEDevice3.GetGattServicesForUuidWithCacheModeAsync") return op diff --git a/dm40/nanowinbt/radio.py b/shared/nanowinbt/radio.py similarity index 93% rename from dm40/nanowinbt/radio.py rename to shared/nanowinbt/radio.py index 2f44826..67e43f5 100644 --- a/dm40/nanowinbt/radio.py +++ b/shared/nanowinbt/radio.py @@ -8,8 +8,6 @@ class NanoRadioStateError(RuntimeError): _INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value _RADIO_FUNCS = None -# byref holds a strong ref to the c_ulong via PyCArgObject.obj (Py_NewRef); -# safe to cache at module level — verified against CPython 3.10–3.14 source. _FIND_RADIO_PARAMS_PTR = ctypes.byref(ctypes.c_ulong(4)) # sizeof(BLUETOOTH_FIND_RADIO_PARAMS) diff --git a/dm40/nanowinbt/scanner.py b/shared/nanowinbt/scanner.py similarity index 95% rename from dm40/nanowinbt/scanner.py rename to shared/nanowinbt/scanner.py index e747b8d..cb50808 100644 --- a/dm40/nanowinbt/scanner.py +++ b/shared/nanowinbt/scanner.py @@ -1,8 +1,8 @@ from .. import mini_asyncio as asyncio -from . import ctypes_winrt as w +from ..nanowinbt import ctypes_winrt as w from .ctypes_com import RoSession from .radio import ensure_bluetooth_radio_on, get_bluetooth_radio_state -from dm40.types import NanoBLEDevice +from shared.types import NanoBLEDevice class NanoScanner: @@ -33,15 +33,18 @@ async def _discover(timeout: float, *, scanning_mode: str = "active", on_device= devices: dict[int, NanoBLEDevice] = {} def handle_received(addr: int, rssi: int, name: str | None, device: NanoBLEDevice | None) -> None: + changed = False if device is None: device = NanoBLEDevice(address=_format_bdaddr(addr), name=name, rssi=rssi, bluetooth_address=addr) devices[addr] = device + changed = True else: device.rssi = rssi if name and not device.name: device.name = name + changed = True - if on_device is not None: + if changed and on_device is not None: on_device(device) def on_received(_sender, args_ptr) -> None: diff --git a/dm40/theme_store.py b/shared/theme_store.py similarity index 96% rename from dm40/theme_store.py rename to shared/theme_store.py index 8b5117c..c7daa76 100644 --- a/dm40/theme_store.py +++ b/shared/theme_store.py @@ -1,4 +1,4 @@ -from dm40.types import ThemePalette +from shared.types import ThemePalette _COLORS_BLOCK = 7 * 10 diff --git a/dm40/types.py b/shared/types.py similarity index 100% rename from dm40/types.py rename to shared/types.py diff --git a/shims/ctypes_shim.py b/shims/ctypes_shim.py index 1d8d11e..4500b34 100644 --- a/shims/ctypes_shim.py +++ b/shims/ctypes_shim.py @@ -19,13 +19,13 @@ class c_long(_SimpleCData): class c_ulong(_SimpleCData): _type_ = "L" +class c_ubyte(_SimpleCData): + _type_ = "B" + # Windows LLP64: int == long == 4, longlong == 8 (always) c_int = c_long c_uint = c_ulong -class c_ubyte(_SimpleCData): - _type_ = "B" - class c_void_p(_SimpleCData): _type_ = "P" diff --git a/utils/theme_store_builder.py b/utils/theme_store_builder.py index 2a98e92..cad85b0 100644 --- a/utils/theme_store_builder.py +++ b/utils/theme_store_builder.py @@ -60,7 +60,7 @@ def serialize_theme_store(themes: list[tuple[str, ...]]) -> bytes: def main() -> None: try: - from dm40.theme_store import deserialize_theme_store_palettes + from shared.theme_store import deserialize_theme_store_palettes except ModuleNotFoundError as exc: if __package__ in (None, ""): raise SystemExit( From 5e5c490ac9d4768ea611a710c25febc8b5175977 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maj=20Sokli=C4=8D?= Date: Fri, 24 Apr 2026 19:45:58 +0200 Subject: [PATCH 3/5] feat(el15): add EL15 electronic load handler Add an EL15 handler covering live voltage, current, power, and temperature telemetry, the waveform view, and on-device controls (load on/off, CC/CV/CR/CP/CAP/DCR mode switching, setpoint entry). Device-only modes (POW, ADV [L], ADV [S], DT) are reflected in the UI as a single disabled status button. --- el15/app.py | 317 +++++++++++++++++++++++++++++++++++++ el15/protocol_constants.py | 168 ++++++++++++++++++++ 2 files changed, 485 insertions(+) create mode 100644 el15/app.py create mode 100644 el15/protocol_constants.py diff --git a/el15/app.py b/el15/app.py new file mode 100644 index 0000000..b7de26b --- /dev/null +++ b/el15/app.py @@ -0,0 +1,317 @@ +"""EL15 device handler.""" +import tkinter as tk +from tkinter import ttk + +from shared.ble_worker import BleWorker +from GUI.themed_messagebox import show_error + +from .protocol_constants import ( + EL15Status, + HEADER, + POLL_PKT, + CMD_LOAD_OFF, + CMD_LOAD_ON, + CMD_MODE_PREFIX, + MODE_NAMES, + MODE_CC, MODE_CV, MODE_CR, MODE_CP, MODE_CAP, MODE_DCR, + MODE_ADV, MODE_POWER, MODE_DT, MODE_ADV_SCAN, MODE_POWER_RPT, + build_set_setpoint_cmd, + parse_status_packet, +) + +_MODES = ( + ("CC", MODE_CC), + ("CV", MODE_CV), + ("CR", MODE_CR), + ("CP", MODE_CP), + ("CAP", MODE_CAP), + ("DCR", MODE_DCR), +) +_UNREACHABLE = (MODE_ADV, MODE_POWER, MODE_DT, MODE_ADV_SCAN, MODE_POWER_RPT) +_HIDE_TEMP = (MODE_CAP, MODE_DCR, MODE_ADV, MODE_POWER, MODE_DT, MODE_ADV_SCAN, MODE_POWER_RPT) +_HIDE_RUNTIME = (MODE_DCR, MODE_ADV, MODE_POWER, MODE_DT, MODE_ADV_SCAN, MODE_POWER_RPT) + + +def _el15_notify_filter(data: bytes) -> bool: + return data[:4] == HEADER + + +_FMT6 = ("%.5f", "%.4f", "%.3f", "%.2f", "%.1f") + + +def _fmt6(v: float) -> str: + """Format value with a fixed 6-digit width: decimal floats with magnitude.""" + av = v if v >= 0 else -v + return _FMT6[(av >= 10) + (av >= 100) + (av >= 1000) + (av >= 10000)] % v + + +class EL15Handler: + title = "EL15" + csv_prefix = "EL15" + + def __init__(self, app) -> None: + self.app = app + self._last_status: EL15Status | None = None + self._last_valid_mode: int = MODE_CC + self._mode_var = tk.IntVar(value=MODE_CC) + self._load_var = tk.BooleanVar(value=False) + self._all_controls: list = [] + + def create_worker(self, device, **callbacks): + return BleWorker( + device, + poll_cmd=POLL_PKT, + notify_hook=_el15_notify_filter, + write_buf_size=10, + cmd_requires_notify=False, + **callbacks, + ) + + def build_menubar_labels(self, pre: tk.Frame, post: tk.Frame) -> None: + self._mode_label_var = tk.StringVar(value="EL15") + tk.Label( + pre, textvariable=self._mode_label_var, + font=("Segoe UI", 11, "bold"), + ).pack(side="left") + + def build_reading_area(self, parent: tk.Frame) -> None: + readings = tk.Frame(parent) + readings.pack(fill=tk.X) + readings.columnconfigure(0, weight=1) + readings.columnconfigure(1, weight=1) + readings.columnconfigure(2, weight=1) + + def _make_cell(col: int, header: str) -> ttk.Label: + cell = tk.Frame(readings) + cell.grid(row=0, column=col, sticky="ew", padx=(0, 12 if col < 2 else 0)) + tk.Label(cell, text=header, font=("Segoe UI", 11)).pack(anchor="w") + val_lbl = ttk.Label( + cell, text="---", font=("Cascadia Mono", 36, "bold"), + style="DM40.BigValue.TLabel", anchor="e", width=10, + ) + val_lbl.pack(fill="x") + return val_lbl + + self._volt_label = _make_cell(0, "Voltage") + self._amp_label = _make_cell(1, "Current") + self._watt_label = _make_cell(2, "Power") + + info_bar = tk.Frame(parent) + info_bar.pack(fill=tk.X, pady=(6, 0)) + + self._info_mode_var = tk.StringVar(value="Mode: ---") + self._info_load_var = tk.StringVar(value="Load: OFF Lock: OFF") + self._info_setp_var = tk.StringVar(value="Setpoint: ---") + self._info_runtime_var = tk.StringVar(value="Runtime: --:--:--") + self._info_temp_var = tk.StringVar(value="Temp: ---") + self._info_fan_var = tk.StringVar(value="Fan: -") + self._info_warn_var = tk.StringVar(value="") + + self._info_labels: dict[str, tk.Label] = {} + for col, (key, var) in enumerate(( + ("mode", self._info_mode_var), ("load", self._info_load_var), + ("setp", self._info_setp_var), ("runtime", self._info_runtime_var), + ("temp", self._info_temp_var), ("fan", self._info_fan_var), + ("warn", self._info_warn_var), + )): + lbl = tk.Label(info_bar, textvariable=var, font=("Consolas", 10)) + lbl.grid(row=0, column=col, sticky="w", padx=(0, 18)) + self._info_labels[key] = lbl + + def build_control_bar(self, bar: tk.Frame) -> None: + self._mode_buttons: list[ttk.Radiobutton] = [] + for label, mode_val in _MODES: + btn = ttk.Radiobutton( + bar, text=label, style="MenuBar.TCheckbutton", + variable=self._mode_var, value=mode_val, + command=lambda m=mode_val: self._on_mode_clicked(m), + ) + btn.pack(side=tk.LEFT, padx=(0, 6)) + self._mode_buttons.append(btn) + # Single disabled radio that reflects whichever unreachable mode is + # active (POW [A] / POW [DT] / ADV). + self._unreach_mode = MODE_POWER + self._unreach_btn = ttk.Radiobutton( + bar, text="---", style="MenuBar.TCheckbutton", + variable=self._mode_var, value=self._unreach_mode, + ) + self._unreach_btn.state(["disabled"]) + self._unreach_btn.pack(side=tk.LEFT, padx=(0, 6)) + + self._load_btn = ttk.Checkbutton( + bar, text="Load", style="MenuBar.TCheckbutton", + variable=self._load_var, command=self._on_load_clicked, + ) + self._load_btn.pack(side=tk.LEFT, padx=(0, 18)) + + tk.Label(bar, text="Setpoint:").pack(side=tk.LEFT, padx=(0, 4)) + self._setpoint_var = tk.StringVar(value="") + self._setpoint_entry = ttk.Entry(bar, textvariable=self._setpoint_var, width=10) + self._setpoint_entry.pack(side=tk.LEFT, padx=(0, 4)) + self._setpoint_entry.bind("", self._on_set_setpoint) + self._setpoint_unit_var = tk.StringVar(value="A") + tk.Label(bar, textvariable=self._setpoint_unit_var, width=2).pack( + side=tk.LEFT, padx=(0, 6) + ) + ttk.Button(bar, text="Set", style="MenuBar.TButton", + command=self._on_set_setpoint, padding=6, width=0).pack(side=tk.LEFT) + + self._all_controls = [ + *self._mode_buttons, + self._load_btn, + self._setpoint_entry, + ] + self.set_control_state(False) + + def set_control_state(self, enabled: bool) -> None: + state = "!disabled" if enabled else "disabled" + for widget in self._all_controls: + widget.state([state]) + + def pre_connect_reset(self) -> None: pass + def clear_capture(self) -> None: pass + def on_connected(self) -> None: pass + def teardown(self) -> None: pass + def refresh_status(self, now: float) -> None: pass + + def on_packet(self, data: bytes) -> None: + app = self.app + s = parse_status_packet(data) + app._append_raw_text(f"RX {s.raw} CRC:{s.crc_str}\n") + + if not s.valid: + return + + self._last_status = s + self._apply_status_buttons(s) + + if s.ready: + if s.mode == MODE_DCR: + self._amp_label.configure(text=f"{_fmt6(s.dcr_i1)} A") + self._watt_label.configure(text=f"{s.dcr_mohm:.1f} m\u03a9") + else: + self._amp_label.configure(text=f"{_fmt6(s.current)} A") + self._watt_label.configure(text=f"{_fmt6(s.power)} W") + self._info_load_var.set( + f"Load: {'ON' if s.load_on else 'OFF'} Lock: {'ON' if s.lock_on else 'OFF'}" + ) + if s.mode == MODE_CAP: + self._info_setp_var.set( + f"Energy: {s.energy_wh:.4f} Wh Cap: {s.capacity_ah:.4f} Ah" + ) + elif s.mode == MODE_DCR: + self._info_setp_var.set( + f"I1: {s.dcr_i1:.3f} A I2: {s.dcr_i2:.3f} A R: {s.dcr_mohm:.1f} m\u03a9" + ) + elif s.setpoint_in_packet: + self._info_setp_var.set( + f"{s.setpoint_label}: {s.setpoint:.{s.setpoint_decimals}f} {s.setpoint_unit}" + ) + rs = s.runtime + self._info_runtime_var.set( + "Runtime: %02d:%02d:%02d" % (rs // 3600, (rs % 3600) // 60, rs % 60) + ) + self._mode_label_var.set(f"EL15 [LOAD {'ON' if s.load_on else 'OFF'}]") + else: + self._amp_label.configure(text="--- A") + self._watt_label.configure(text="--- W") + self._info_load_var.set("Load: --- Lock: ---") + self._info_setp_var.set(f"{s.setpoint_label}: ---") + self._info_runtime_var.set("Runtime: --:--:--") + if s.warning: + self._mode_label_var.set(f"EL15 [PROT: {s.warning}]") + else: + self._mode_label_var.set("EL15 [MENU]") + + self._volt_label.configure(text=f"{_fmt6(s.voltage)} V") + self._info_mode_var.set(f"Mode: {s.mode_name}") + if not s.warning: + self._info_temp_var.set(f"Temp: {s.temperature:.3f}\u00b0C") + self._info_fan_var.set(f"Fan: {s.fan_speed}/5") + if s.warning: + self._info_warn_var.set(f"\u26a0 {s.warning}") + else: + self._info_warn_var.set("") + + # Hide fields the current mode doesn't report. + mode = s.mode + hide_temp = mode in _HIDE_TEMP + hide_runtime = mode in _HIDE_RUNTIME + hide_setp = mode in _UNREACHABLE + for key, hide in ( + ("temp", hide_temp), ("runtime", hide_runtime), ("setp", hide_setp), + ("warn", not s.warning), + ): + lbl = self._info_labels[key] + if hide: + lbl.grid_remove() + else: + lbl.grid() + + if s.ready: + if s.mode == MODE_DCR: + app._wave_view.push( + s.voltage, pad=0.5, axis_unit="V", axis_mul=1.0, decimals=3, + tooltip_extra=f"I1: {s.dcr_i1:.3f} A\nI2: {s.dcr_i2:.3f} A\nR: {s.dcr_mohm:.1f} m\u03a9", + ) + else: + app._wave_view.push( + s.voltage, pad=0.5, axis_unit="V", axis_mul=1.0, decimals=3, + tip_value_label=f"U: {_fmt6(s.voltage)} V", + tooltip_extra=f"I: {_fmt6(s.current)} A\nP: {_fmt6(s.power)} W", + ) + smin, smax, avg = app._push_stats(s.voltage) + app._stats_var.set("V Min %.3f Max %.3f Avg %.3f" % (smin, smax, avg)) + + if app._is_connected: + app._rate_count += 1 + + def _apply_status_buttons(self, s: EL15Status) -> None: + # CAP|fault == CC|fault and DCR|fault == CV|fault at the byte level, + # so keep the last known good mode and use it for display during faults. + if not s.warning: + self._last_valid_mode = s.mode + display_mode = self._last_valid_mode + unreachable = display_mode in _UNREACHABLE + if unreachable: + if display_mode != self._unreach_mode: + self._unreach_mode = display_mode + self._unreach_btn.configure(value=display_mode) + self._unreach_btn.configure(text=MODE_NAMES[display_mode]) + elif self._unreach_btn.cget("text") != "---": + self._unreach_btn.configure(text="---") + self._mode_var.set(display_mode) + self._load_var.set(s.load_on) + self._setpoint_unit_var.set(s.setpoint_unit) + self._setpoint_entry.state(["disabled" if unreachable else "!disabled"]) + if unreachable: + self._setpoint_var.set("") + # Keep the setpoint entry synced to the device unless the user is editing + # it. In CAP mode the packet doesn't carry a setpoint, so preserve the + # last value the user typed. + if ( + s.ready and s.setpoint_in_packet + and self._setpoint_entry.focus_get() is not self._setpoint_entry + ): + self._setpoint_var.set(f"{s.setpoint:.{s.setpoint_decimals}f}") + + def _on_mode_clicked(self, mode_val: int) -> None: + # Revert the radio until the device confirms via the next status packet. + self._mode_var.set(self._last_valid_mode) + self.app.send_command(CMD_MODE_PREFIX + bytes((mode_val,))) + + def _on_load_clicked(self) -> None: + last = self._last_status + desired_on = self._load_var.get() + # Revert visible toggle; status packet will update it once the device responds. + self._load_var.set(bool(last and last.load_on)) + self.app.send_command(CMD_LOAD_ON if desired_on else CMD_LOAD_OFF) + + def _on_set_setpoint(self, _event=None) -> None: + try: + value = float(self._setpoint_var.get().strip()) + except ValueError: + show_error(self.app, "Setpoint", "Enter a valid numeric value.", + theme=(self.app.ui.theme.bg, self.app.ui.theme.outline)) + return + self.app.send_command(build_set_setpoint_cmd(value)) diff --git a/el15/protocol_constants.py b/el15/protocol_constants.py new file mode 100644 index 0000000..b085c0b --- /dev/null +++ b/el15/protocol_constants.py @@ -0,0 +1,168 @@ +"""EL15 protocol constants and packet parsing.""" + +HEADER = b"\xdf\x07\x03\x08" +# Pre-computed poll packet (CMD_QUERY prefix + CRC byte 0x3F) +POLL_PKT = b"\xaf\x07\x03\x08\x00\x3f" + +_SETPOINT_PREFIX = b"\xaf\x07\x03\x04\x04" +_setpoint_fbuf = bytearray(4) +_setpoint_fview = memoryview(_setpoint_fbuf).cast('f') + +CMD_LOAD_ON = b"\xaf\x07\x03\x09\x01\x04" +CMD_LOAD_OFF = b"\xaf\x07\x03\x09\x01\x00" +CMD_LOCK = b"\xaf\x07\x03\x09\x01\x01" +CMD_MODE_PREFIX = b"\xaf\x07\x03\x03\x01" + +MODE_CC = 0x01 +MODE_CAP = 0x02 +MODE_DT = 0x03 +MODE_ADV = 0x04 +MODE_CV = 0x09 +MODE_DCR = 0x0A +MODE_POWER = 0x0B +MODE_ADV_SCAN = 0x0C +MODE_POWER_RPT = 0x0D +MODE_CR = 0x11 +MODE_CP = 0x19 + +MODE_NAMES = { + MODE_CC: "CC", + MODE_CAP: "CAP", + MODE_DT: "POW [DT]", + MODE_ADV: "ADV [L]", + MODE_CV: "CV", + MODE_DCR: "DCR", + MODE_POWER: "POW [A]", + MODE_ADV_SCAN: "ADV [S]", + MODE_POWER_RPT: "POW [RPT]", + MODE_CR: "CR", + MODE_CP: "CP", +} + +# (unit_str, decimal_places, label) +MODE_SETPOINT_INFO = { + MODE_CC: ("A", 3, "Current"), + MODE_CAP: ("A", 3, "Current"), + MODE_CV: ("V", 3, "Voltage"), + MODE_DCR: ("A", 3, "Current"), + MODE_CR: ("Ω", 1, "Resistance"), + MODE_CP: ("W", 2, "Power"), + MODE_ADV: ("", 3, ""), + MODE_POWER: ("", 3, ""), + MODE_DT: ("", 3, ""), + MODE_ADV_SCAN: ("", 3, ""), + MODE_POWER_RPT: ("", 3, ""), +} + + +def build_set_setpoint_cmd(value: float) -> bytes: + _setpoint_fview[0] = value + return _SETPOINT_PREFIX + bytes(_setpoint_fbuf) + + +# Status byte 6 bit layout: bit1=load, bit2=lock; upper nibble=protection code +_STATUS_LOAD_BIT = 0x02 +_STATUS_LOCK_BIT = 0x04 +# Status byte 5 layout: bits 0-4 = mode (bit2 is warning flag), bits 5-7 = fan speed +_MODE_MASK = 0x1F +_B5_WARN_FLAG = 0x06 # bits 1+2 are both set when protection has tripped +FAN_SPEED_MAX = 5 + +# Upper nibble of byte6 when _B5_WARN_FLAG is set +_WARN_NAMES = {0x6: "REV", 0x9: "UVP"} + + +class EL15Status: + __slots__ = ( + "raw", "crc_str", "valid", + "voltage", "current", "power", "runtime", "temperature", "setpoint", + "energy_wh", "capacity_ah", + "dcr_mohm", "dcr_i1", "dcr_i2", + "mode", "mode_name", "fan_speed", "load_on", "lock_on", "ready", + "setpoint_unit", "setpoint_decimals", "setpoint_label", + "setpoint_in_packet", "warning", + ) + + def __init__(self): + self.raw = "" + self.crc_str = "" + self.valid = False + self.voltage = self.current = self.power = 0.0 + self.runtime = 0 + self.temperature = self.setpoint = 0.0 + self.energy_wh = self.capacity_ah = 0.0 + self.dcr_mohm = self.dcr_i1 = self.dcr_i2 = 0.0 + self.mode = MODE_CC + self.mode_name = "---" + self.fan_speed = 0 + self.load_on = False + self.lock_on = False + self.ready = False + self.setpoint_unit = "A" + self.setpoint_decimals = 3 + self.setpoint_label = "Current" + self.setpoint_in_packet = True + self.warning = "" + + +def parse_status_packet(data: bytes) -> EL15Status: + """Parse a 28-byte EL15 status notification into EL15Status.""" + s = EL15Status() + s.raw = data.hex(" ").upper() + s.crc_str = "PASS" if (sum(data) & 0xFF) == 0 else "FAIL" + if len(data) < 28 or data[:4] != HEADER: + return s + mv = memoryview(data) + s.voltage = mv[7:11].cast('f')[0] + s.current = mv[11:15].cast('f')[0] + s.runtime = mv[15:19].cast('i')[0] + s.power = s.voltage * s.current + b5 = data[5] + b6 = data[6] + # Bits 1+2 of byte5 are BOTH set when protection has tripped. Each bit + # individually is part of normal mode encoding (CAP=0x02, DCR=0x0A etc.), + # so the fault test must require both bits set simultaneously. + # Bit 0 is the "ready/measuring" flag for CC/CV/CR/CP. + warn_flag = (b5 & _B5_WARN_FLAG) == _B5_WARN_FLAG + raw_mode = (b5 & (_MODE_MASK & ~_B5_WARN_FLAG)) if warn_flag else (b5 & _MODE_MASK) + mode = raw_mode if raw_mode in MODE_NAMES else (raw_mode | 0x01) + s.mode = mode + if warn_flag: + warn_code = b6 >> 4 + s.warning = _WARN_NAMES.get(warn_code, "PROT %X" % warn_code) + s.ready = False + else: + s.ready = (raw_mode & 0x01) != 0 or mode in (MODE_CAP, MODE_DCR, MODE_ADV, MODE_POWER, MODE_DT, MODE_ADV_SCAN, MODE_POWER_RPT) + # Bytes [15:19], [19:23] and [23:27] carry mode-specific measurements. + # CC/CV/CR/CP: runtime(i), temperature(f), setpoint(f) + # CAP: runtime(i), energy(f, mWh), capacity(f, mAh) + # DCR: I1(f, A), I2(f, A), resistance(f, m\u03a9); [11:15] unused + # ADV/POWER: unused (V/I only; power computed) + if mode == MODE_CAP: + s.energy_wh = mv[19:23].cast('f')[0] * 0.001 + s.capacity_ah = mv[23:27].cast('f')[0] * 0.001 + s.setpoint_in_packet = False + elif mode == MODE_DCR: + s.dcr_i1 = mv[15:19].cast('f')[0] + s.dcr_i2 = mv[19:23].cast('f')[0] + s.dcr_mohm = mv[23:27].cast('f')[0] + s.runtime = 0 + s.current = 0.0 + s.power = 0.0 + s.setpoint_in_packet = False + elif mode in (MODE_ADV, MODE_POWER, MODE_DT, MODE_POWER_RPT): + s.runtime = 0 + s.setpoint_in_packet = False + else: + s.temperature = mv[19:23].cast('f')[0] + s.setpoint = mv[23:27].cast('f')[0] + # Fan speed (0=off..5=max) is split across two bytes: + # byte5 bits 6-7 -> low 2 bits, byte6 bit 0 -> MSB. Byte5 bit 5 is unused. + s.fan_speed = (b5 >> 6) | ((b6 & 0x01) << 2) + s.load_on = (b6 & _STATUS_LOAD_BIT) != 0 + s.lock_on = (b6 & _STATUS_LOCK_BIT) != 0 + s.mode_name = MODE_NAMES.get(mode, "?%02X" % mode) + info = MODE_SETPOINT_INFO.get(mode, ("?", 3, "Setpoint")) + s.setpoint_unit, s.setpoint_decimals, s.setpoint_label = info + s.valid = True + return s From 38e60d13a49b809f57d57fe210def0d2a9a7aaa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maj=20Sokli=C4=8D?= Date: Fri, 24 Apr 2026 19:46:09 +0200 Subject: [PATCH 4/5] chore: inline waveform tooltip, tighten build, refresh docs - waveform_view: draw the hover/pin tooltip directly on the canvas and remove the standalone Tooltip widget, reducing widget churn and binary size. - build_release.cmd: small flag tweak for the size-optimized release build. - README: restructure with a contents outline, consolidated features, device support with EL15 coverage, and cleaner protocol notes. - utils/themes.md: minor wording pass. --- GUI/widgets/tooltip.py | 115 ----------- GUI/widgets/waveform_view.py | 133 +++++++------ README.md | 365 +++++++++++++++++++++-------------- build_release.cmd | 2 +- utils/themes.md | 35 ++-- 5 files changed, 312 insertions(+), 338 deletions(-) delete mode 100644 GUI/widgets/tooltip.py diff --git a/GUI/widgets/tooltip.py b/GUI/widgets/tooltip.py deleted file mode 100644 index e48aa34..0000000 --- a/GUI/widgets/tooltip.py +++ /dev/null @@ -1,115 +0,0 @@ -import tkinter as tk - - -class Tooltip: - """Theme-aware tooltip with hover delay.""" - - def __init__( - self, - master: tk.Misc, - font_obj, - ): - self.master = master - self._font = font_obj - self._tip = None - self._label = None - self._current_text = None - self._last_xy = None - self._after_id = None - self._pending = None - - def is_visible(self) -> bool: - return self._tip is not None - - def _cancel_pending_show(self) -> None: - if self._after_id: - try: - self.master.after_cancel(self._after_id) - except tk.TclError: - pass - self._after_id = None - self._pending = None - - def update_text(self, text: str) -> bool: - self._cancel_pending_show() - - if self._current_text == text: - return True - - if not self._tip or not self._label: - return False - - try: - self._label.configure(text=text) - except tk.TclError: - pass - self._current_text = text - return True - - def show(self, text: str, x: int, y: int, delay_ms: int = 150): - if self._tip: - self.update_text(text) - self.move(x, y) - return - - pending = (text, x, y) - if self._after_id: - if self._pending == pending: - return - self._cancel_pending_show() - self._pending = pending - self._after_id = self.master.after(delay_ms, self._materialize) - - def _materialize(self): - self._after_id = None - if not self._pending: - return - text, x, y = self._pending - self._pending = None - self._create_tip(text, x, y) - - def _create_tip(self, text: str, x: int, y: int): - if self._tip: - return - self._tip = tk.Toplevel(self.master) - self._tip.wm_overrideredirect(True) - self._label = tk.Label( - self._tip, - text=text, - wraplength=520, - justify="left", - font=self._font, - padx=5, - pady=3, - highlightthickness=2 - ) - self._label.pack(fill=tk.BOTH, expand=True) - try: - self._tip.wm_geometry(f"+{x}+{y}") - except tk.TclError: - pass - self._current_text = text - self._last_xy = (x, y) - - def move(self, x: int, y: int): - if self._tip: - xy = (x, y) - if self._last_xy == xy: - return - try: - self._tip.wm_geometry(f"+{x}+{y}") - except tk.TclError: - pass - self._last_xy = xy - - def hide(self): - self._cancel_pending_show() - if self._tip: - try: - self._tip.destroy() - except tk.TclError: - pass - self._tip = None - self._label = None - self._current_text = None - self._last_xy = None diff --git a/GUI/widgets/waveform_view.py b/GUI/widgets/waveform_view.py index 0a39264..e58a1e8 100644 --- a/GUI/widgets/waveform_view.py +++ b/GUI/widgets/waveform_view.py @@ -1,23 +1,21 @@ -"""Waveform view for DM40 readings.""" - import time import tkinter as tk from _collections import deque # type: ignore -from dm40.types import ThemePalette - -from .tooltip import Tooltip - class WaveformView(tk.Canvas): GRID_FRACS = (0.25, 0.5, 0.75) _DRAG_PX = 5 + _TIP_PAD_X = 6 + _TIP_PAD_Y = 4 + _TIP_MARGIN = 12 - def __init__(self, master: tk.Misc, *, colors: ThemePalette, capacity: int = 600): + def __init__(self, master: tk.Misc, *, colors, capacity: int = 600): super().__init__(master, highlightthickness=2, bd=0) self._cap = max(16, int(capacity)) self._buf: deque[float] = deque(maxlen=self._cap) self._ts: deque[str] = deque(maxlen=self._cap) + self._extras: deque[tuple[str, str]] = deque(maxlen=self._cap) self._pad = 0.0 self._lo = 0.0 self._hi = 1.0 @@ -31,12 +29,14 @@ def __init__(self, master: tk.Misc, *, colors: ThemePalette, capacity: int = 600 self._ch = 0 self._x_step = 0.0 - # Interaction state + # Interaction state (all coords are canvas-local) self._hover_idx: int | None = None - self._last_ptr: tuple[int, int] | None = None + self._last_x = 0 + self._last_y = 0 self._hover_visible = False + self._tip_visible = False self._paused = False - self._pause_queue: list[tuple[float, str, float]] = [] + self._pause_queue: list[tuple[float, str, float, str]] = [] self._tracked_idx: int | None = None self._sel_range: tuple[int, int] | None = None self._sel_visible = False @@ -56,7 +56,8 @@ def __init__(self, master: tk.Misc, *, colors: ThemePalette, capacity: int = 600 self._sel_rect = self.create_rectangle( 0, 0, 0, 0, state="hidden", stipple="gray12", width=1, tags=("sel",) ) - self._tooltip = Tooltip(master, "TkDefaultFont") + self._tip_rect = self.create_rectangle(0, 0, 0, 0, state="hidden", width=2, tags=("tip",)) + self._tip_text = self.create_text(0, 0, text="", state="hidden", anchor="nw", tags=("tip",)) for seq, cb in ( ("", self._on_configure), ("", self._on_motion), @@ -87,19 +88,44 @@ def _px_to_range(self, x1: int, x2: int) -> tuple[int, int]: max(0, min(self._x_to_idx(max(x1, x2)), last)), ) + # In-canvas tooltip + + def _show_tip(self, text: str, x: int, y: int) -> None: + """Draw tooltip rect + text at canvas coord (x, y), clamping to bounds.""" + self.itemconfigure(self._tip_text, text=text, state="normal") + bb = self.bbox(self._tip_text) + if not bb: + return + w = bb[2] - bb[0] + 2 * self._TIP_PAD_X + h = bb[3] - bb[1] + 2 * self._TIP_PAD_Y + m = self._TIP_MARGIN + if x + w > self._cw - m: x = self._cw - w - m + if y + h > self._ch - m: y = self._ch - h - m + if x < m: x = m + if y < m: y = m + self.coords(self._tip_rect, x, y, x + w, y + h) + self.coords(self._tip_text, x + self._TIP_PAD_X, y + self._TIP_PAD_Y) + if not self._tip_visible: + self.itemconfigure(self._tip_rect, state="normal") + self._tip_visible = True + + def _hide_tip(self) -> None: + if not self._tip_visible: + return + self.itemconfigure("tip", state="hidden") + self._tip_visible = False + # Hover / point display def _clear_hover(self) -> None: - if not self._hover_visible and not self._tooltip.is_visible(): - return self._hover_idx = None - self._tooltip.hide() + self._hide_tip() if self._hover_visible: self.itemconfigure("hover", state="hidden") self._hover_visible = False - def _show_point(self, idx: int, root_x: int, root_y: int, delay_ms: int = 0) -> None: - """Position hover line/dot and show tooltip for buffer index *idx*.""" + def _show_point(self, idx: int, tip_x: int, tip_y: int) -> None: + """Position hover line/dot and tooltip for buffer index *idx*.""" self._hover_idx = idx h = self._ch x = self._idx_to_x(idx) @@ -111,10 +137,12 @@ def _show_point(self, idx: int, root_x: int, root_y: int, delay_ms: int = 0) -> if not self._hover_visible: self.itemconfigure("hover", state="normal") self._hover_visible = True - self._tooltip.show( - f"{self._axis_label(value)}\n{self._ts[idx]}", - root_x, root_y, delay_ms=delay_ms, - ) + tip_label, extra = self._extras[idx] + text = tip_label or self._axis_label(value) + if extra: + text = f"{text}\n{extra}" + text = f"{text}\n{self._ts[idx]}" + self._show_tip(text, tip_x, tip_y) # Pause @@ -134,9 +162,10 @@ def _drain_pause_queue(self) -> None: overflow = max(0, len(self._buf) + len(queue) - self._cap) if overflow: self._shift_indices(overflow) - for value, ts, _ in queue: + for value, ts, _, tip_label, extra in queue: self._buf.append(value) self._ts.append(ts) + self._extras.append((tip_label, extra)) self.redraw() @property @@ -156,17 +185,13 @@ def _draw_sel(self, s: int, e: int) -> None: if not self._sel_visible: self.itemconfigure(self._sel_rect, state="normal") self._sel_visible = True - bbox = self.bbox(self._sel_rect) - tip_x = self.winfo_rootx() + (bbox[2] if bbox else int(round(xr))) + 12 snap = [self._buf[i] for i in range(s, e + 1)] vmin, vmax = min(snap), max(snap) - self._tooltip.show( + self._show_tip( f"Min: {self._axis_label(vmin)} Max: {self._axis_label(vmax)} " f"\u0394: {self._axis_label(vmax - vmin)}\n" f"{self._ts[s]} \u2192 {self._ts[e]} ({e - s + 1} pts)", - tip_x, - self.winfo_rooty() + 12, - delay_ms=0, + int(round(xr)) + 12, 12, ) def _clear_selection(self) -> None: @@ -174,7 +199,7 @@ def _clear_selection(self) -> None: if self._sel_visible: self.itemconfigure(self._sel_rect, state="hidden") self._sel_visible = False - self._tooltip.hide() + self._hide_tip() # Index shifting on deque overflow @@ -207,7 +232,6 @@ def _on_configure(self, _e=None) -> None: self.redraw() def _on_leave(self, _e=None) -> None: - self._last_ptr = None if self._tracked_idx is None and self._sel_range is None: self._clear_hover() @@ -216,10 +240,9 @@ def _on_motion(self, event) -> None: return idx = self._x_to_idx(event.x) if 0 <= idx < len(self._buf): - ptr = (event.x_root, event.y_root) - if self._hover_idx != idx or self._last_ptr != ptr: - self._last_ptr = ptr - self._show_point(idx, ptr[0] + 12, ptr[1] + 12, delay_ms=120) + self._last_x = event.x + self._last_y = event.y + self._show_point(idx, event.x + 12, event.y + 12) else: self._clear_hover() @@ -258,11 +281,7 @@ def _on_release(self, event) -> None: idx = self._x_to_idx(event.x) if 0 <= idx < len(self._buf): self._tracked_idx = idx - self._show_point( - idx, - self.winfo_rootx() + int(self._idx_to_x(idx)) + 12, - self.winfo_rooty() + 12, - ) + self._show_point(idx, int(self._idx_to_x(idx)) + 12, 12) else: self._clear_tracking() @@ -272,8 +291,8 @@ def _dismiss(self, _e=None) -> None: # Theme - def set_colors(self, colors: ThemePalette) -> None: - fg, trace, grid = colors.text, colors.accent, colors.outline + def set_colors(self, colors) -> None: + fg, trace, grid, bg = colors.text, colors.accent, colors.outline, colors.bg self.itemconfigure(self._trace_line, fill=trace) self.itemconfigure(self._hover_line, fill=grid) self.itemconfigure(self._hover_dot, fill=trace, outline=trace) @@ -281,12 +300,15 @@ def set_colors(self, colors: ThemePalette) -> None: self.itemconfigure(self._bot_text, fill=fg) self.itemconfigure("grid", fill=grid) self.itemconfigure(self._sel_rect, fill=trace, outline=trace) + self.itemconfigure(self._tip_rect, fill=bg, outline=grid) + self.itemconfigure(self._tip_text, fill=fg) # Data def clear(self) -> None: self._buf.clear() self._ts.clear() + self._extras.clear() self._pause_queue.clear() self._clear_tracking() self._clear_selection() @@ -301,6 +323,8 @@ def push( axis_unit: str | None = None, axis_mul: float | None = None, decimals: int | None = None, + tip_value_label: str = "", + tooltip_extra: str = "", ) -> None: if axis_unit is not None: self._axis_unit = axis_unit @@ -309,13 +333,14 @@ def push( if decimals is not None: self._decimals = max(0, min(9, decimals)) if self._paused: - self._pause_queue.append((value, time.strftime("%H:%M:%S"), pad)) + self._pause_queue.append((value, time.strftime("%H:%M:%S"), pad, tip_value_label, tooltip_extra)) return self._pad = pad if len(self._buf) == self._cap: self._shift_indices() self._buf.append(value) self._ts.append(time.strftime("%H:%M:%S")) + self._extras.append((tip_value_label, tooltip_extra)) if self._rec_file is not None: self._rec_file.write(f"{self._ts[-1]},{value}\n") self.redraw() @@ -350,18 +375,13 @@ def redraw(self) -> None: if self._tracked_idx is not None: idx = self._tracked_idx if 0 <= idx < len(values): - self._show_point( - idx, - self.winfo_rootx() + int(self._idx_to_x(idx)) + 12, - self.winfo_rooty() + 12, - ) + self._show_point(idx, int(self._idx_to_x(idx)) + 12, 12) else: self._clear_tracking() - elif self._hover_idx is not None and self._last_ptr is not None: + elif self._hover_idx is not None: idx = self._hover_idx if idx < len(values): - px, py = self._last_ptr - self._show_point(idx, px + 12, py + 12, delay_ms=120) + self._show_point(idx, self._last_x + 12, self._last_y + 12) if self._sel_range is not None: s, e = self._sel_range if e >= len(values): @@ -369,24 +389,15 @@ def redraw(self) -> None: else: self._draw_sel(s, e) - # CSV save / record── - - def save_buffer_csv(self, path: str) -> int: - """Write current buffer to *path* as CSV. Returns row count.""" + def save_buffer_csv(self, path: str) -> None: with open(path, "w", newline="") as f: f.write("Timestamp,Value\n") for ts, v in zip(self._ts, self._buf): - f.write(f"{ts},{v}\n") - return len(self._buf) + f.write("%s,%s\n" % (ts, v)) - def toggle_recording(self, path: str) -> bool: - """Start or stop CSV recording. Returns new recording state.""" - if self._rec_file is not None: - self.stop_recording() - return False + def toggle_recording(self, path: str) -> None: self._rec_file = open(path, "w", newline="") self._rec_file.write("Timestamp,Value\n") - return True def stop_recording(self) -> None: if self._rec_file is not None: diff --git a/README.md b/README.md index 7d5683c..1e6caea 100644 --- a/README.md +++ b/README.md @@ -1,128 +1,192 @@ # DM40GUI -[![Build](https://github.com/maj113/DM40GUI/actions/workflows/build.yml/badge.svg)](https://github.com/maj113/DM40GUI/actions/workflows/build.yml) [![GitHub Release](https://img.shields.io/github/v/release/maj113/DM40GUI)](https://github.com/maj113/DM40GUI/releases/latest) +[![Build](https://github.com/maj113/DM40GUI/actions/workflows/build.yml/badge.svg)](https://github.com/maj113/DM40GUI/actions/workflows/build.yml) +[![GitHub Release](https://img.shields.io/github/v/release/maj113/DM40GUI)](https://github.com/maj113/DM40GUI/releases/latest) +[![License](https://img.shields.io/github/license/maj113/DM40GUI)](LICENSE) +[![Platform](https://img.shields.io/badge/platform-Windows%2010%201703%2B-0078d4)](#requirements) -DM40GUI is a Windows desktop app for DM40-series multimeters over Bluetooth Low Energy (BLE). +> A Windows desktop app for **DM40-series multimeters** and **EL15 electronic loads** over Bluetooth Low Energy. +> Tkinter front-end, a custom WinRT BLE transport, and zero third-party Python dependencies at runtime. -- Windows only (if someone wants to maintain a non-Bleak Linux BLE stack let me know) -- Tkinter UI -- Pure Python runtime from source (no third-party runtime dependencies) +

+ DM40GUI waveform view with a range selection +

+ +> [!TIP] +> Prebuilt binaries are available on the [Releases](https://github.com/maj113/DM40GUI/releases/latest) page. -## BRANDING MATERIAL NEEDED (ICON&BANNER), WILL PAY!!! +> [!WARNING] +> Windows only. The transport layer talks to WinRT BLE GATT directly; there is no maintained Linux or macOS port. -## Go to the [Release](https://github.com/maj113/DM40GUI/releases/latest) page to download the latest build +## Contents + +- [Features](#features) +- [Device Support](#device-support) +- [Requirements](#requirements) +- [Run from Source](#run-from-source) +- [Build for Release](#build-for-release) +- [Keybinds](#keybinds) +- [Architecture](#architecture) +- [Protocol Notes](#protocol-notes) +- [Notes](#notes) +- [License](#license) ## Features -### Live Readout and Waveform +- One binary, two device families: handlers are dispatched dynamically after the device is identified. +- Scrolling waveform with live value, pause/resume, click-to-pin, drag-to-select range with min / max / Δ. +- Raw packet inspector with inline CRC pass/fail tags and integrated find popup. +- One-shot CSV buffer export or continuous CSV recording. +- 17 built-in themes with a live preview browser; selection is persisted across sessions. +- Release build is tuned for small size: Nuitka one-file + size-oriented MSVC flags + aggressive stdlib pruning. + +## Device Support -- Scrolling waveform graph with live value display -- Pause/resume updates -- CSV save and record for waveform data -- Min/Max/Avg stats strip aligned with current display scaling -- Click to pin a sample, drag to select a range — shows min/max/delta and timestamp span +| Device | Status | UI / Telemetry | Controls | +|---|---|---|---| +| DM40 series | Supported | Live reading, waveform, raw packet view, stats | Hold, auto, relative, range and mode switching | +| EL15 | Experimental | Voltage / current / power, waveform, raw packet view, stats | Load on/off, CC / CV / CR / CP / CAP / DCR mode switching, setpoint entry |

- DM40 graph range selection with min/max/delta tooltip + Built-in theme browser preview

-### DMM Controls +### DM40 -- Hold, auto-range, relative, and mode/range switching -- Status indicators (charge, screen lock and hold) are also displayed -- Almost 100% feature compatibility with atk-xtool +- Large primary reading with auxiliary values. +- Mode, range, battery, charging, lock, and hold state rendering. +- Hold, auto-range, relative, capacitance, diode / continuity, frequency, and temperature controls. +- Roughly matches `atk-xtool` feature coverage for day-to-day meter use. -### Raw Packet Inspector +### EL15 -- Hex packet log with inline CRC pass/fail tags -- Find tool (`Ctrl+F` / `F3` / `Shift+F3`) +- Dedicated voltage, current, and power readout cells. +- Runtime, temperature, fan speed, mode, load state, and setpoint display. +- CC, CV, CR, CP, CAP (battery capacity), and DCR (DC resistance) mode switching from the main control bar. +- CAP mode shows accumulated energy (Wh) and capacity (Ah) in place of the setpoint. +- DCR mode shows the I1 / I2 test currents and the measured milliohm resistance. +- Device-only modes (`POW [A]`, `POW [DT]`, `ADV [L]`, `ADV [S]`) surface as a single disabled radio that tracks the active mode; the setpoint entry is disabled in these modes. +- Load control and editable setpoint command entry. -### Themes +## Requirements -- 17 built-in themes, persisted across sessions -- Theme browser with live preview +- **Windows 10 version 1703** (Creators Update, build 15063) or newer. The BLE GATT APIs this app relies on (`IBluetoothLEDevice3`, `IGattDeviceService3`) were introduced in that release; `IBluetoothLEDevice6` connection tuning is used when available and skipped otherwise. +- **Python 3.13+** with Tkinter (source runtime only). +- **MSVC Build Tools** compatible with Nuitka `--msvc=latest` (release build only). -

- Built-in theme cycling -

+> [!NOTE] +> Other Python versions may work, but are currently untested. -## Requirements +## Run from Source -Minimum: **Windows 10 version 1703** (Creators Update, build 15063). -The BLE GATT APIs used (`IBluetoothLEDevice3`, `IGattDeviceService3`) were introduced in that release. -Connection parameter tuning (`IBluetoothLEDevice6`) is available on Windows 11+ and silently skipped on older builds. +```powershell +py -3.13 main.py +``` -Runtime (source): +No extra package install is required for source execution. -- Windows 10 1703+ -- Python 3.13+ with Tkinter +## Build for Release -Release build: +Install the build dependency: -- Python 3.13+ -- Nuitka -- MSVC Build Tools (Nuitka `--msvc=latest` target) +```powershell +py -3.13 -m pip install --upgrade nuitka +``` -Notes: Older python versions untested. +Then run: -## Keybinds +```powershell +build_release.cmd +``` -Global app shortcuts: +The release path is intentionally size-focused: -| Keybind | Action | -|---|---| -| `P` | Pause/resume waveform updates | -| `R` | Start/stop waveform CSV recording | -| `Ctrl+S` | Save current waveform buffer to CSV | -| `Ctrl+C` | Copy current reading text | +- one-file Nuitka build +- size-oriented compiler and linker flags +- precompiled minimal `ctypes` shim for frozen builds +- aggressive exclusion of unused stdlib modules and Tcl/Tk payloads +- optional module-closure reports for auditing import growth -Raw packet viewer shortcuts: +The build script is CI-friendly: -| Keybind | Action | -|---|---| -| `Ctrl+F` | Open find popup in raw packet panel | -| `Enter` | Next find result | -| `Shift+Enter` | Previous find result | -| `F3` | Next find result | -| `Shift+F3` | Previous find result | -| `Esc` | Close find popup / clear active focus state | +- deterministic compiler and linker environment setup +- non-zero exit on failure +- configurable via `DM40_*` environment variables -Waveform mouse actions: +Build environment variables: -| Action | Behavior | +| Variable | Purpose | |---|---| -| Left click on trace | Pin tooltip at a sample | -| Left-click drag | Select range and show min/max/delta | -| Right click | Clear pinned point / clear selection | +| `DM40_PYTHON` | Python launcher or command (default: `py -3.13`) | +| `DM40_OUT_DIR` | Build output directory | +| `DM40_CCFLAGS` | Additional compiler flags | +| `DM40_LINKFLAGS` | Additional linker flags | +| `DM40_NUITKA_FLAGS` | Additional Nuitka flags | +| `DM40_MODE_FLAGS` | Build mode flags (default: `--deployment`) | +| `DM40_CONSOLE_MODE` | Nuitka console mode (`disable`, `attach`, `force`) | +| `DM40_MSVC` | Nuitka MSVC selector (default: `latest`) | +| `DM40_JOBS` | Parallel compile jobs | +| `DM40_EMIT_MODULE_REPORTS` | Emit `modules.txt` and XML report (`1` local default, `0` when `CI` is set) | -## Project Layout +Minimal CI example: + +```cmd +set DM40_PYTHON=py -3.13 +set DM40_OUT_DIR=build\ci\nuitka +call build_release.cmd +``` + +## Keybinds + +| Scope | Keybind | Action | +|---|---|---| +| Global | `P` | Pause or resume waveform updates | +| Global | `R` | Start or stop waveform CSV recording | +| Global | `Ctrl+S` | Save the current waveform buffer to CSV | +| Global | `Ctrl+C` | Copy the current reading text | +| Raw packet view | `Ctrl+F` | Open the find popup | +| Raw packet view | `Enter` / `F3` | Next match | +| Raw packet view | `Shift+Enter` / `Shift+F3` | Previous match | +| Raw packet view | `Esc` | Close the find popup or clear its focus | +| Waveform | Left click on trace | Pin a tooltip at a sample | +| Waveform | Left-click drag | Select a range and show min / max / Δ | +| Waveform | Right click | Clear the pinned point or current selection | + +## Architecture | Path | Description | |---|---| -| `main.py` | App entry point | -| `dm40/app.py` | Tk app and UI event loop integration | -| `dm40/ble_worker.py` | BLE transport worker thread | -| `dm40/parsing.py` | Packet decode to UI model | -| `dm40/protocol_constants.py` | UUIDs, command bytes, mode/range maps | -| `GUI/` | Widgets, controls, theme UI | +| `main.py` | Entry point; installs frozen-build shims before launching the app | +| `shared/base_app.py` | Single-window Tk app, scan/connect flow, waveform view, and handler selection | +| `shared/ble_worker.py` | Shared BLE worker plus device-family probe | +| `shared/device_registry.py` | Single-source registry of supported device handlers | +| `shared/nanowinbt/` | Custom Windows BLE and WinRT transport layer | +| `shared/mini_asyncio.py` | Small async runtime used instead of full `asyncio` | +| `dm40/app.py` | DM40 handler, controls, and UI updates | +| `dm40/parsing.py` | DM40 packet parsing and meter-state decoding | +| `dm40/protocol_constants.py` | DM40 commands, flags, scale maps, and mode groups | +| `el15/app.py` | EL15 handler, controls, and UI updates | +| `el15/protocol_constants.py` | EL15 commands and status packet parsing | +| `GUI/` | Shared widgets, controls, theming, and custom dialogs | | `build_release.cmd` | One-file Nuitka build script | ## Protocol Notes -Reverse-engineered from BLE HCI packets with Wireshark. +Reverse-engineered from BLE HCI captures and device traffic inspection. + +### Shared BLE Endpoints -### BLE Endpoints +Both device families currently use the same BLE service and characteristic layout in the shared worker: | Direction | UUID | |---|---| -| Notify (DMM -> PC) | `0000fff1-0000-1000-8000-00805f9b34fb` | -| Write (PC -> DMM) | `0000fff3-0000-1000-8000-00805f9b34fb` | +| Service | `0000fff0-0000-1000-8000-00805f9b34fb` | +| Notify | `0000fff1-0000-1000-8000-00805f9b34fb` | +| Write | `0000fff3-0000-1000-8000-00805f9b34fb` | -Defined in `dm40/protocol_constants.py`. +### DM40 Protocol -### Command Frames - -All commands are 6 bytes: +DM40 command frames are 6 bytes: ```text AF 05 03 @@ -134,9 +198,7 @@ Checksum formula: (-sum(first_5_bytes)) & 0xFF ``` -Built by `_build_command_packet` in `dm40/app.py`. - -Known commands: +Common commands: ```text CMD_ID af 05 03 08 00 41 @@ -146,29 +208,30 @@ CMD_HOLD_OFF af 05 03 04 01 00 CMD_AUTO_ON af 05 03 03 01 01 CMD_AUTO_OFF af 05 03 03 01 00 CMD_RELATIVE af 05 03 05 01 01 +CMD_CAP af 05 03 06 01 03 +CMD_DIODE af 05 03 06 01 04 +CMD_CONT af 05 03 06 01 44 +CMD_HZ af 05 03 06 01 05 +CMD_TEMP af 05 03 06 01 45 ``` -### Notifications - -Two packet families: +DM40 notifications use two main packet families: -- Model ID: prefix `DF 05 03 08 14`. -- Measurement: prefix `DF 05 03 09`, decoded by `parse_measurement_for_ui`. +- Model ID: prefix `DF 05 03 08 14` +- Measurement: prefix `DF 05 03 09` -### Measurement Decode - -Minimum packet length: 16 bytes. +DM40 measurement decode summary: | Byte(s) | Field | |---|---| -| `data[5]` | Mode/range flag (`FLAG_INFO`) | +| `data[5]` | Mode and range flag (`FLAG_INFO`) | | `data[6]` | Status byte | -| `data[14:16]` | Primary counts (m1, little-endian) | -| `data[12:14]` | Secondary counts (m2, little-endian) | -| `data[10:12]` | Tertiary counts (m3, little-endian) | -| `data[-8]` | Scale/sign slot 1 | -| `data[-9]` | Scale/sign slot 2 | -| `data[-10]` | Scale/sign slot 3 | +| `data[14:16]` | Primary counts (`m1`, little-endian) | +| `data[12:14]` | Secondary counts (`m2`, little-endian) | +| `data[10:12]` | Tertiary counts (`m3`, little-endian) | +| `data[-8]` | Scale and sign slot 1 | +| `data[-9]` | Scale and sign slot 2 | +| `data[-10]` | Scale and sign slot 3 | CRC check: @@ -176,9 +239,7 @@ CRC check: (sum(all_bytes) & 0xFF) == 0 ``` -### Status Byte - -`data[6]` bitfield: +DM40 status byte (`data[6]`) summary: | Bits | Meaning | |---|---| @@ -187,71 +248,91 @@ CRC check: | `& 0x40` | Screen lock | | `& 0x80` | Hold | -## Notes: - -### Stability -The project is still in an early state, I cannot guarantee measurement correctness for multiple reasons: +### EL15 Protocol -1. The DMM doesn't expose all measurement scale/range byte variations unless I am measuring in that specific scale, without decade boxes I'm unable to verify all scale/ranges due to this, if the App crashes with a KeyError the scale is probably not implemented yet, please report this. +EL15 status notifications use header: -2. I do NOT have a lot of time to work on this. +```text +DF 07 03 08 +``` -### Disclaimer -This project is not affiliated with, endorsed by, or associated with Alientek or any of its subsidiaries. +EL15 poll command: -### Use of AI -Claude Opus 4.6 used to write (some of) the Readme files +```text +AF 07 03 08 00 3F +``` -## Run from Source +Common EL15 commands: -```powershell -py -3.13 main.py +```text +CMD_LOAD_ON af 07 03 09 01 04 +CMD_LOAD_OFF af 07 03 09 01 00 ``` -No additional runtime package install is required for source execution. +Mode switch commands share a common prefix `AF 07 03 03 01 `: -## Build for Release +| Mode | ID | Command | +|---|---|---| +| CC | `0x01` | `af 07 03 03 01 01` | +| CAP | `0x02` | `af 07 03 03 01 02` | +| CV | `0x09` | `af 07 03 03 01 09` | +| DCR | `0x0A` | `af 07 03 03 01 0a` | +| CR | `0x11` | `af 07 03 03 01 11` | +| CP | `0x19` | `af 07 03 03 01 19` | -Install build dependency: +Device-only modes observed in status packets, cannot be set from the app -```powershell -py -3.13 -m pip install --upgrade nuitka -``` +| Mode | ID | Label | +|---|---|---| +| Power dynamic test | `0x03` | `POW [DT]` | +| Advanced list | `0x04` | `ADV [L]` | +| Power (auto) | `0x0B` | `POW [A]` | +| Advanced scan | `0x0C` | `ADV [S]` | -Run build script: +Setpoint command layout: -```powershell -build_release.cmd +```text +AF 07 03 04 04 ``` -The script is CI-friendly: +The EL15 parser treats valid status packets as 28-byte frames. The fixed fields are: -- deterministic compiler/linker env setup -- non-zero exit on failure -- configurable via `DM40_*` environment variables +| Byte(s) | Field | +|---|---| +| `data[5] & 0x1F` | Mode ID (`ready` bit folded in for CC/CV/CR/CP) | +| `data[5] & 0x01` | Ready / measuring flag (clear while in device menus) | +| `(data[5] >> 6) \| ((data[6] & 0x01) << 2)` | Fan speed (0-5) | +| `data[6] & 0x02` | Load on | +| `data[6] & 0x04` | Panel lock | +| `data[7:11]` | Voltage (`float32`) | +| `data[11:15]` | Current (`float32`, unused in DCR/ADV/POW) | -Build environment variables: +Bytes `[15:19]`, `[19:23]`, `[23:27]` are mode-specific: -| Variable | Purpose | -|---|---| -| `DM40_PYTHON` | Python launcher/command (default: `py -3.13`) | -| `DM40_OUT_DIR` | Build output directory | -| `DM40_CCFLAGS` | Additional compiler flags | -| `DM40_LINKFLAGS` | Additional linker flags | -| `DM40_NUITKA_FLAGS` | Additional Nuitka flags | -| `DM40_MODE_FLAGS` | Build mode flags (default: `--deployment`) | -| `DM40_CONSOLE_MODE` | Nuitka console mode (`disable`, `attach`, `force`) | -| `DM40_MSVC` | Nuitka MSVC selector (default: `latest`) | -| `DM40_JOBS` | Parallel compile jobs | -| `DM40_EMIT_MODULE_REPORTS` | Emit `modules.txt` and compilation XML report (`1` local default, `0` default when `CI` is set) | +| Mode | `[15:19]` | `[19:23]` | `[23:27]` | +|---|---|---|---| +| CC / CV / CR / CP | Runtime (`int32`, s) | Temperature (`float32`, °C) | Setpoint (`float32`) | +| CAP | Runtime (`int32`, s) | Energy (`float32`, mWh) | Capacity (`float32`, mAh) | +| DCR | I1 (`float32`, A) | I2 (`float32`, A) | Resistance (`float32`, mΩ) | +| ADV [L] / ADV [S] / POW [A] / POW [DT] | unused | unused | unused | -Minimal CI example (Windows CMD shell): +Derived values shown in the UI: -```cmd -set DM40_PYTHON=py -3.13 -set DM40_OUT_DIR=build\ci\nuitka -call build_release.cmd -``` +- Power is computed as `voltage * current` +- CAP / DCR reuse the setpoint info row to show energy/capacity or I1/I2/R respectively +- Mode label is resolved from the mode byte via `MODE_NAMES` +- Setpoint unit and precision depend on the active EL15 mode + +## Notes + +> [!WARNING] +> This project is still in an early reverse-engineering stage. DM40 scale and range coverage is incomplete, and unknown packet variants can still surface as missing flag support. + +> [!NOTE] +> If the app crashes with a DM40 `KeyError` during parsing, that usually means a scale or range flag has not been mapped yet. Please report it with the raw packet if possible. + +> [!NOTE] +> This project is not affiliated with, endorsed by, or associated with Alientek or any of its subsidiaries. ## License diff --git a/build_release.cmd b/build_release.cmd index f0b5f27..75f0103 100644 --- a/build_release.cmd +++ b/build_release.cmd @@ -2,7 +2,7 @@ setlocal EnableExtensions EnableDelayedExpansion set "DEFAULT_MODE_FLAGS=--deployment" -set "DEFAULT_CONSOLE_MODE=disable" +set "DEFAULT_CONSOLE_MODE=force" set "DEFAULT_MSVC=latest" set "DEFAULT_PYTHON=py -3.13" set "DEFAULT_OUT_DIR=build\ci\nuitka" diff --git a/utils/themes.md b/utils/themes.md index ee5aab0..a6be47d 100644 --- a/utils/themes.md +++ b/utils/themes.md @@ -7,7 +7,7 @@ This file explains how themes are defined and loaded. `GUI/theme_manager.py` is the runtime source of truth. - Theme bytes are embedded in `_DEFAULT_STORE`. -- `ThemeManager` loads palettes with `deserialize_theme_store_palettes(_DEFAULT_STORE)`. +- `ThemeManager` loads palettes with `deserialize_theme_store_palettes(_DEFAULT_STORE)` from `shared.theme_store`. - Active theme selection is index-based. Theme persistence uses the Windows registry: @@ -19,41 +19,38 @@ Only the selected index is persisted. Theme definitions are static until `_DEFAU ## Theme Schema -Themes map to `ThemePalette` in `dm40/types.py` and must provide exactly 14 fields in this order: +Themes map to `ThemePalette` in `shared/types.py` and must provide exactly 11 fields in this order: 1. `name` 2. `bg` -3. `panel` -4. `widget` -5. `text` -6. `muted` -7. `accent` -8. `accent_hover` -9. `accent_pressed` -10. `outline` -11. `alt_text` -12. `hover` -13. `button` -14. `button_pressed` +3. `widget` +4. `text` +5. `accent` +6. `accent_hover` +7. `accent_pressed` +8. `outline` +9. `alt_text` +10. `hover` +11. `button` All color values must use `#RRGGBB` format. ## Embedded Store Format -`dm40/theme_store.py` encodes themes as back-to-back records with no global header. +`shared/theme_store.py` decodes themes as back-to-back records with no global header. Per theme record: - 1 byte: name length - N bytes: name (`latin1`) -- 91 bytes: color payload (`13 * 7`) +- 70 bytes: color payload (`10 * 7`) -Validation in `serialize_theme_store` enforces: +Validation in `utils/theme_store_builder.py` enforces: - at least one theme -- exactly 14 fields per theme +- exactly 11 fields per theme - name length <= 255 bytes -- color payload length of 91 +- color payload length of 70 - each color starts with `#` ## Updating Themes From b484345b4b9b10c925e8fbaab23b1b14a5d4e2f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maj=20Sokli=C4=8D?= Date: Sat, 25 Apr 2026 13:44:03 +0200 Subject: [PATCH 5/5] fix: Nuitka doesn't handle dynamic imports well, explicitly import Co-authored-by: Copilot --- shared/device_registry.py | 37 ++++++++++++++----------------------- 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/shared/device_registry.py b/shared/device_registry.py index 1d03c3d..769df5a 100644 --- a/shared/device_registry.py +++ b/shared/device_registry.py @@ -1,35 +1,26 @@ -"""Device registry: one place to declare a new handler. - -Each entry maps a device-type string to: - (handler_module, handler_class, name_prefix, discovery_family_bytes) - -- `handler_module` / `handler_class` are imported lazily so unused handlers - stay out of the frozen binary's startup path. -- `name_prefix` is matched case-insensitively against the BLE advertised name. -- `discovery_family_bytes` are bytes 5-6 of the reply to the probe command - sent by `shared.ble_worker.probe_device_type`. -""" +_METADATA: dict[str, tuple[str, bytes]] = { + "DM40": ("DM40", b"\x05\x03"), + "EL15": ("EL15", b"\x07\x03"), +} -DEVICE_REGISTRY: dict[str, tuple[str, str, str, bytes]] = { - "DM40": ("dm40.app", "DM40Handler", "DM40", b"\x05\x03"), - "EL15": ("el15.app", "EL15Handler", "EL15", b"\x07\x03"), +FAMILY_MAP: dict[bytes, str] = { + family: dtype for dtype, (_, family) in _METADATA.items() } def guess_device_type(name: str) -> str | None: upper = name.upper() - for dtype, (_, _, prefix, _) in DEVICE_REGISTRY.items(): + for dtype, (prefix, _) in _METADATA.items(): if upper.startswith(prefix): return dtype return None def load_handler(dtype: str, app): - module_name, class_name, _, _ = DEVICE_REGISTRY[dtype] - module = __import__(module_name, fromlist=(class_name,)) - return getattr(module, class_name)(app) - - -FAMILY_MAP: dict[bytes, str] = { - family: dtype for dtype, (_, _, _, family) in DEVICE_REGISTRY.items() -} + if dtype == "DM40": + from dm40.app import DM40Handler + return DM40Handler(app) + if dtype == "EL15": + from el15.app import EL15Handler + return EL15Handler(app) + raise KeyError(f"Unknown device type: {dtype!r}")