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 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
@@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "niimprint"
|
name = "niimprint"
|
||||||
version = "0.1.0"
|
version = "0.2.1"
|
||||||
description = ""
|
description = ""
|
||||||
authors = []
|
authors = []
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user