From 6f6aaca1bf5a6ff4d9e0add69a86f43468604705 Mon Sep 17 00:00:00 2001 From: Gracious <14353802+GraciousGpal@users.noreply.github.com> Date: Mon, 21 Sep 2026 08:59:05 +0100 Subject: [PATCH] Add printer discovery and self-update; v0.2.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Find… button lists serial ports and paired Bluetooth devices (bluetoothctl on Linux, PnP device properties on Windows) and fills in the connection - Startup check against the releases API; update bar with Update now / Release notes / Later / Skip this version; manual check via the version link. Standalone builds download the new executable and replace themselves via a small handoff script, then relaunch - README: connection setup and updater notes --- label_app.py | 328 ++++++++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 2 +- readme.md | 8 +- 3 files changed, 333 insertions(+), 5 deletions(-) diff --git a/label_app.py b/label_app.py index 2c093e2..064b6c1 100644 --- a/label_app.py +++ b/label_app.py @@ -5,10 +5,15 @@ select, drag to move, arrow keys to nudge, rotate/resize from the side panel. """ import json +import os import re +import subprocess import sys +import tempfile import threading import tkinter as tk +import urllib.request +import webbrowser from dataclasses import asdict, dataclass, field from pathlib import Path from tkinter import filedialog @@ -16,6 +21,7 @@ from tkinter import filedialog import emoji_data_python import ttkbootstrap as ttk from PIL import Image, ImageDraw, ImageFont, ImageOps, ImageTk +from serial.tools import list_ports from ttkbootstrap.dialogs import Messagebox from niimprint import ( @@ -27,6 +33,12 @@ from niimprint import ( prepare_print, ) +APP_VERSION = "0.2.1" +UPDATE_REPO = "gracious/NIIMBOT-D101" +UPDATE_API = f"https://git.gracious.one/api/v1/repos/{UPDATE_REPO}/releases/latest" +UPDATE_ASSET = "NiimbotLabel.exe" if sys.platform == "win32" else "NiimbotLabel" +FROZEN = bool(getattr(sys, "frozen", False)) # running from a PyInstaller bundle + PX_PER_MM = 8 SETTINGS_PATH = Path.home() / ".config" / "niimprint-label" / "settings.json" DEFAULT_ADDR = "" # printer Bluetooth MAC / serial port; remembered once entered @@ -297,6 +309,136 @@ class Design: ) +# --------------------------------------------------------------------------- devices + + +def _run(cmd: list[str], timeout: float = 10) -> str: + try: + kw = {} + if sys.platform == "win32": + kw["creationflags"] = 0x08000000 # CREATE_NO_WINDOW + return subprocess.run( + cmd, capture_output=True, text=True, timeout=timeout, **kw + ).stdout + except (OSError, subprocess.SubprocessError): + return "" + + +def find_devices() -> list[tuple[str, str, str]]: + """Return [(conn, address, description)] for serial ports and paired BT devices.""" + found = [] + for port in list_ports.comports(): + if port.description and port.description != "n/a": # skip legacy ttyS* + found.append(("usb", port.device, port.description)) + if sys.platform == "win32": + ps = ( + "Get-PnpDevice -Class Bluetooth -Status OK | ForEach-Object { " + "$a = (Get-PnpDeviceProperty -InstanceId $_.InstanceId " + "-KeyName DEVPKEY_Bluetooth_DeviceAddress -ErrorAction SilentlyContinue).Data; " + 'if ($a) { "$a|$($_.FriendlyName)" } }' + ) + for line in _run(["powershell", "-NoProfile", "-Command", ps]).splitlines(): + if "|" in line: + raw, name = line.strip().split("|", 1) + raw = raw.replace(":", "").replace("-", "").upper() + if len(raw) == 12: + mac = ":".join(raw[i : i + 2] for i in range(0, 12, 2)) + found.append(("bluetooth", mac, name)) + elif sys.platform.startswith("linux"): + for line in _run(["bluetoothctl", "devices"]).splitlines(): + parts = line.split(" ", 2) + if len(parts) == 3 and parts[0] == "Device": + found.append(("bluetooth", parts[1], parts[2])) + return found + + +def _looks_like_printer(desc: str) -> bool: + d = desc.lower() + return any(k in d for k in ("niimbot", "b21", "b1-", "b18", "d11", "d101", "d110")) + + +# --------------------------------------------------------------------------- updates + + +def _version_tuple(v: str) -> tuple[int, ...]: + return tuple(int(x) for x in re.findall(r"\d+", v)[:3]) or (0,) + + +def check_for_update() -> dict | None: + """Query the releases API; return {version, url, asset_url, notes} if newer.""" + req = urllib.request.Request( + UPDATE_API, headers={"User-Agent": f"NiimbotLabel/{APP_VERSION}"} + ) + with urllib.request.urlopen(req, timeout=8) as resp: + rel = json.load(resp) + version = rel.get("tag_name", "").lstrip("v") + if _version_tuple(version) <= _version_tuple(APP_VERSION): + return None + asset_url = next( + ( + a["browser_download_url"] + for a in rel.get("assets", []) + if a["name"] == UPDATE_ASSET + ), + None, + ) + return { + "version": version, + "url": rel.get("html_url", ""), + "asset_url": asset_url, + "notes": rel.get("body", ""), + } + + +def download(url: str, dest: Path, progress=None): + req = urllib.request.Request( + url, headers={"User-Agent": f"NiimbotLabel/{APP_VERSION}"} + ) + with urllib.request.urlopen(req, timeout=30) as resp, open(dest, "wb") as out: + total = int(resp.headers.get("Content-Length") or 0) + done = 0 + while chunk := resp.read(256 * 1024): + out.write(chunk) + done += len(chunk) + if progress: + progress(done, total) + + +def swap_executable_and_restart(new_file: Path): + """Replace the running bundle with new_file once we exit, then relaunch.""" + exe = Path(sys.executable) + pid = os.getpid() + if sys.platform == "win32": + script = exe.parent / "NiimbotLabel-update.bat" + script.write_text( + "@echo off\r\n" + f':wait\r\ntasklist /FI "PID eq {pid}" | find "{pid}" >nul && ' + "(timeout /t 1 /nobreak >nul & goto wait)\r\n" + f'move /y "{new_file}" "{exe}" >nul\r\n' + f'start "" "{exe}"\r\n' + 'del "%~f0"\r\n' + ) + subprocess.Popen( + ["cmd", "/c", str(script)], + creationflags=0x00000008 + | 0x00000200, # DETACHED_PROCESS | NEW_PROCESS_GROUP + close_fds=True, + ) + else: + script = exe.parent / ".niimbotlabel-update.sh" + script.write_text( + "#!/bin/sh\n" + f"while kill -0 {pid} 2>/dev/null; do sleep 0.5; done\n" + f"mv -f '{new_file}' '{exe}' && chmod +x '{exe}'\n" + f"rm -f '{script}'\n" + f"exec '{exe}'\n" + ) + script.chmod(0o755) + subprocess.Popen( + ["/bin/sh", str(script)], start_new_session=True, close_fds=True + ) + + # --------------------------------------------------------------------------- app @@ -357,6 +499,7 @@ class LabelApp(ttk.Window): for var in (self.model, self.conn, self.addr): var.trace_add("write", lambda *_: self._disconnect()) self._apply_preset() + self.after(1500, self.check_updates) if not self.design.items: self.add_text("Hello 👋") self._select(self.design.items[-1]) @@ -365,6 +508,30 @@ class LabelApp(ttk.Window): # ---- UI construction def _build_ui(self): + self.update_bar = ttk.Frame(self, padding=(12, 6), bootstyle="info") + self.update_label = ttk.Label(self.update_bar, bootstyle="inverse-info") + self.update_label.pack(side="left") + ttk.Button( + self.update_bar, text="Skip this version", bootstyle="info-link", + command=lambda: self._dismiss_update(skip=True), + ).pack(side="right") # fmt: skip + ttk.Button( + self.update_bar, text="Later", bootstyle="info-link", + command=self._dismiss_update, + ).pack(side="right") # fmt: skip + self.update_notes_btn = ttk.Button( + self.update_bar, text="Release notes", bootstyle="info-outline", + command=lambda: webbrowser.open(self._update["url"]), + ) # fmt: skip + self.update_notes_btn.pack(side="right", padx=(0, 8)) + self.update_btn = ttk.Button( + self.update_bar, + text="Update now", + bootstyle="light", + command=self.apply_update, + ) + self.update_btn.pack(side="right", padx=(0, 8)) + root = ttk.Frame(self, padding=12) root.pack(fill="both", expand=True) root.columnconfigure(1, weight=1) @@ -524,9 +691,12 @@ class LabelApp(ttk.Window): width=9, ).grid(row=0, column=3, sticky="w") ttk.Label(box, text="Address").grid(row=1, column=0, sticky="w") - ttk.Entry(box, textvariable=self.addr, width=22).grid( - row=1, column=1, columnspan=3, sticky="we", pady=(4, 0) + ttk.Entry(box, textvariable=self.addr, width=16).grid( + row=1, column=1, columnspan=2, sticky="we", pady=(4, 0) ) + ttk.Button( + box, text="Find…", command=self.find_printer, bootstyle="secondary-outline" + ).grid(row=1, column=3, sticky="e", padx=(6, 0), pady=(4, 0)) ttk.Label(box, text="Density").grid(row=2, column=0, sticky="w") ttk.Spinbox(box, from_=1, to=5, textvariable=self.density, width=4).grid( row=2, column=1, sticky="w", pady=(4, 0) @@ -662,6 +832,10 @@ class LabelApp(ttk.Window): ttk.Label(bar, textvariable=self.status, bootstyle="secondary").pack( side="right", padx=(0, 12) ) + ttk.Button( + bar, text=f"v{APP_VERSION}", bootstyle="secondary-link", + command=lambda: self.check_updates(manual=True), + ).pack(side="left", padx=(12, 0)) # fmt: skip # ---- theme @@ -924,6 +1098,155 @@ class LabelApp(ttk.Window): self.design.render()[0].convert("1").save(path) self.status.set(f"Saved {Path(path).name}") + # ---- device discovery + + def find_printer(self): + """List serial ports and paired Bluetooth devices; pick one to fill in.""" + self.status.set("Searching for devices…") + self.update_idletasks() + devices = find_devices() + self.status.set("Ready") + if not devices: + Messagebox.show_info( + "No serial ports or paired Bluetooth devices found.\n\n" + "Pair the printer in your OS Bluetooth settings first, or plug in USB.", + "Find printer", + parent=self, + ) + return + devices.sort(key=lambda d: (not _looks_like_printer(d[2]), d[0], d[2])) + + win = ttk.Toplevel(self) + win.title("Select printer") + win.transient(self) + win.resizable(False, False) + frame = ttk.Frame(win, padding=12) + frame.pack(fill="both", expand=True) + ttk.Label(frame, text="Serial ports and paired Bluetooth devices:").pack( + anchor="w" + ) + cols = ("via", "address", "name") + tree = ttk.Treeview(frame, columns=cols, show="headings", height=8) + for c, w in zip(cols, (90, 170, 320)): + tree.heading(c, text=c.title()) + tree.column(c, width=w, anchor="w") + for conn, addr, desc in devices: + tag = "printer" if _looks_like_printer(desc) else "" + tree.insert("", "end", values=(conn, addr, desc), tags=(tag,)) + tree.tag_configure("printer", foreground=self.style.colors.success) + tree.pack(fill="both", expand=True, pady=(6, 8)) + if tree.get_children(): + tree.selection_set(tree.get_children()[0]) + + def choose(_e=None): + sel = tree.selection() + if sel: + conn, addr, _ = tree.item(sel[0], "values") + self.conn.set(conn) + self.addr.set(addr) + self.status.set(f"Printer set to {addr}") + win.destroy() + + tree.bind("", choose) + btns = ttk.Frame(frame) + btns.pack(fill="x") + ttk.Button(btns, text="Use selected", command=choose, bootstyle="primary").pack( + side="right" + ) + ttk.Button(btns, text="Cancel", command=win.destroy, bootstyle="link").pack( + side="right", padx=(0, 6) + ) + win.grab_set() + + # ---- updates + + def check_updates(self, manual=False): + def work(): + try: + info = check_for_update() + except Exception as exc: # noqa: BLE001 — offline is fine + self.after(0, self._update_checked, None, manual, str(exc)) + return + self.after(0, self._update_checked, info, manual, None) + + if manual: + self.status.set("Checking for updates…") + threading.Thread(target=work, daemon=True).start() + + def _update_checked(self, info, manual, error): + if error: + if manual: + self.status.set("Update check failed") + Messagebox.show_warning( + f"Could not reach the releases page:\n{error}", + "Updates", + parent=self, + ) + return + if not info: + if manual: + self.status.set(f"v{APP_VERSION} is the latest version") + return + if not manual and self.settings.get("skip_version") == info["version"]: + return + self._update = info + self.status.set(f"Update available: v{info['version']}") + self.update_label.configure( + text=f"Version {info['version']} is available (you have {APP_VERSION})." + ) + can_self_update = FROZEN and info["asset_url"] + self.update_btn.configure( + text="Update now" if can_self_update else "Download", + command=self.apply_update + if can_self_update + else lambda: webbrowser.open(info["url"]), + ) + self.update_bar.pack(fill="x", side="top", before=self.winfo_children()[1]) + + def _dismiss_update(self, skip=False): + self.update_bar.pack_forget() + if skip and getattr(self, "_update", None): + self.settings["skip_version"] = self._update["version"] + self._save_settings() + + def apply_update(self): + info = self._update + self.update_btn.configure(state="disabled") + dest = Path(tempfile.gettempdir()) / f"{UPDATE_ASSET}.new" + + def progress(done, total): + pct = f" {done * 100 // total}%" if total else "" + self.after(0, self.status.set, f"Downloading v{info['version']}…{pct}") + + def work(): + try: + download(info["asset_url"], dest, progress) + if sys.platform != "win32": + dest.chmod(0o755) + self.after(0, self._update_downloaded, dest, None) + except Exception as exc: # noqa: BLE001 + self.after(0, self._update_downloaded, dest, exc) + + threading.Thread(target=work, daemon=True).start() + + def _update_downloaded(self, dest: Path, exc): + self.update_btn.configure(state="normal") + if exc: + self.status.set("Update failed") + Messagebox.show_error(str(exc), "Update failed", parent=self) + return + self._save_settings() + try: + swap_executable_and_restart(dest) + except OSError as exc: + Messagebox.show_error( + f"Downloaded to {dest} but could not replace the program:\n{exc}", + "Update failed", + parent=self, + ) + return + self.destroy() + # ---- printing def _connect(self) -> PrinterClient: @@ -993,6 +1316,7 @@ class LabelApp(ttk.Window): "addr": self.addr.get(), "density": self.density.get(), "theme": self.theme_mode, + "skip_version": self.settings.get("skip_version"), "design": self.design.to_dict(), } try: diff --git a/pyproject.toml b/pyproject.toml index d1b8926..d9eacc4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "niimprint" -version = "0.1.0" +version = "0.2.1" description = "" authors = [] diff --git a/readme.md b/readme.md index 30338f0..479d4df 100644 --- a/readme.md +++ b/readme.md @@ -13,7 +13,9 @@ Standalone builds, no Python needed — grab the latest from the [releases page] | `NiimbotLabel.exe` | Windows 10/11 x64 | | `NiimbotLabel` | Linux x64 (needs a system Tcl/Tk) | -Pair the printer over Bluetooth in your OS first, then enter its MAC address in the app's *Printer* box (see [Bluetooth connection](#bluetooth-connection) for which address to use). For USB, leave the address blank to auto-detect, or enter the port (`/dev/ttyACM0`, `COM3`). +Pair the printer in your OS Bluetooth settings first, then click **Find…** in the app's *Printer* box: it lists paired Bluetooth devices and serial ports, with the printer highlighted — double-click it and you're set. (On Windows the paired printer also appears as a *Standard Serial over Bluetooth* `COMx` port, which works just as well.) You can also type the address yourself: a MAC like `AA:BB:CC:DD:EE:FF` for Bluetooth, or a port (`/dev/ttyACM0`, `COM3`) for USB — blank auto-detects a single USB printer. + +The app checks the releases page on startup and offers to update itself when a newer version is published. ## Supported printers @@ -146,10 +148,12 @@ The 40 × 12 mm label above, exactly as sent to the printer (320 × 96 px, 1-bit ### Printing & files +- **Find…** — lists paired Bluetooth devices (via `bluetoothctl` on Linux, PnP device properties on Windows) and serial ports; pick the printer to fill in *Via* and *Address*. - **Print** — connects lazily and prints in the background; *Copies* repeats the label. Errors show in a dialog. - **Save PNG…** exports the 1-bit image; **Save… / Open…** store the design as JSON; **New** clears the label. - Printer model, connection, address, density, theme, and the last design are remembered in `~/.config/niimprint-label/settings.json`. - ☾ / ☀ toggles light and dark themes ([ttkbootstrap](https://ttkbootstrap.readthedocs.io) *flatly* / *darkly*). +- **Updates** — on startup the app asks the releases API for the latest version (one small request, no other data sent). If there is a newer one, a bar offers *Update now*: the standalone build downloads the new executable and replaces itself, a source checkout gets a link to the release. The version link in the bottom bar checks manually. ### Library use @@ -175,7 +179,7 @@ A PyInstaller spec (`label_app.spec`) bundles the app, fonts, emoji data and the .\build-windows.ps1 ``` -produces `dist\NiimbotLabel.exe` (~30 MB, no installation needed). Bluetooth uses the Windows RFCOMM socket API, so pair the printer in Windows Bluetooth settings first, then enter its MAC address in the app. For USB, enter the `COMx` port or leave the address blank to auto-detect. +produces `dist\NiimbotLabel.exe` (~30 MB, no installation needed). Bluetooth uses the Windows RFCOMM socket API: pair the printer in Windows Bluetooth settings, then use **Find…** in the app (or enter the MAC / `COMx` port by hand). `assets/win/fribidi.dll` is bundled because Pillow's Windows wheel needs it for emoji sequences (flags, skin tones, 🧑‍💻-style ZWJ emoji); see `assets/win/README.md`.