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 dfc5a8503b
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 json
import os
import re import re
import subprocess
import sys import sys
import tempfile
import threading import threading
import tkinter as tk import tkinter as tk
import urllib.request
import webbrowser
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from pathlib import Path from pathlib import Path
from tkinter import filedialog from tkinter import filedialog
@@ -16,6 +21,7 @@ from tkinter import filedialog
import emoji_data_python import emoji_data_python
import ttkbootstrap as ttk import ttkbootstrap as ttk
from PIL import Image, ImageDraw, ImageFont, ImageOps, ImageTk from PIL import Image, ImageDraw, ImageFont, ImageOps, ImageTk
from serial.tools import list_ports
from ttkbootstrap.dialogs import Messagebox from ttkbootstrap.dialogs import Messagebox
from niimprint import ( from niimprint import (
@@ -27,6 +33,12 @@ from niimprint import (
prepare_print, 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 PX_PER_MM = 8
SETTINGS_PATH = Path.home() / ".config" / "niimprint-label" / "settings.json" SETTINGS_PATH = Path.home() / ".config" / "niimprint-label" / "settings.json"
DEFAULT_ADDR = "" # printer Bluetooth MAC / serial port; remembered once entered 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 # --------------------------------------------------------------------------- app
@@ -357,6 +499,7 @@ class LabelApp(ttk.Window):
for var in (self.model, self.conn, self.addr): for var in (self.model, self.conn, self.addr):
var.trace_add("write", lambda *_: self._disconnect()) var.trace_add("write", lambda *_: self._disconnect())
self._apply_preset() self._apply_preset()
self.after(1500, self.check_updates)
if not self.design.items: if not self.design.items:
self.add_text("Hello 👋") self.add_text("Hello 👋")
self._select(self.design.items[-1]) self._select(self.design.items[-1])
@@ -365,6 +508,30 @@ class LabelApp(ttk.Window):
# ---- UI construction # ---- UI construction
def _build_ui(self): 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 = ttk.Frame(self, padding=12)
root.pack(fill="both", expand=True) root.pack(fill="both", expand=True)
root.columnconfigure(1, weight=1) root.columnconfigure(1, weight=1)
@@ -524,9 +691,12 @@ class LabelApp(ttk.Window):
width=9, width=9,
).grid(row=0, column=3, sticky="w") ).grid(row=0, column=3, sticky="w")
ttk.Label(box, text="Address").grid(row=1, column=0, sticky="w") ttk.Label(box, text="Address").grid(row=1, column=0, sticky="w")
ttk.Entry(box, textvariable=self.addr, width=22).grid( ttk.Entry(box, textvariable=self.addr, width=16).grid(
row=1, column=1, columnspan=3, sticky="we", pady=(4, 0) 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.Label(box, text="Density").grid(row=2, column=0, sticky="w")
ttk.Spinbox(box, from_=1, to=5, textvariable=self.density, width=4).grid( ttk.Spinbox(box, from_=1, to=5, textvariable=self.density, width=4).grid(
row=2, column=1, sticky="w", pady=(4, 0) 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( ttk.Label(bar, textvariable=self.status, bootstyle="secondary").pack(
side="right", padx=(0, 12) 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 # ---- theme
@@ -924,6 +1098,155 @@ class LabelApp(ttk.Window):
self.design.render()[0].convert("1").save(path) self.design.render()[0].convert("1").save(path)
self.status.set(f"Saved {Path(path).name}") 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 # ---- printing
def _connect(self) -> PrinterClient: def _connect(self) -> PrinterClient:
@@ -993,6 +1316,7 @@ class LabelApp(ttk.Window):
"addr": self.addr.get(), "addr": self.addr.get(),
"density": self.density.get(), "density": self.density.get(),
"theme": self.theme_mode, "theme": self.theme_mode,
"skip_version": self.settings.get("skip_version"),
"design": self.design.to_dict(), "design": self.design.to_dict(),
} }
try: try:
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "niimprint" name = "niimprint"
version = "0.1.0" version = "0.2.1"
description = "" description = ""
authors = [] authors = []
+6 -2
View File
@@ -13,7 +13,9 @@ Standalone builds, no Python needed — grab the latest from the [releases page]
| `NiimbotLabel.exe` | Windows 10/11 x64 | | `NiimbotLabel.exe` | Windows 10/11 x64 |
| `NiimbotLabel` | Linux x64 (needs a system Tcl/Tk) | | `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 ## 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 ### 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. - **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. - **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`. - 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*). - ☾ / ☀ 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 ### Library use
@@ -175,7 +179,7 @@ A PyInstaller spec (`label_app.spec`) bundles the app, fonts, emoji data and the
.\build-windows.ps1 .\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`. `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`.