Add printer discovery and self-update; v0.2.1

- 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
This commit is contained in:
Gracious
2026-09-21 08:59:05 +01:00
parent e3628719c5
commit 6f6aaca1bf
3 changed files with 333 additions and 5 deletions
+326 -2
View File
@@ -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("<Double-1>", 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: