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:
+326
-2
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user