3 Commits
Author SHA1 Message Date
Gracious 6f6aaca1bf 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
2026-09-21 08:59:05 +01:00
Gracious e3628719c5 Update repository links after move to the gracious organisation 2026-09-21 08:45:33 +01:00
Gracious 23c068b269 README for public release; blank default printer address
Download table for release builds, supported-printer table, fork notes,
credits for bundled fonts/libraries. DEFAULT_ADDR is now empty and the
screenshots use a placeholder MAC.
2026-09-21 08:40:46 +01:00
5 changed files with 378 additions and 32 deletions
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 201 KiB

+327 -3
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,9 +33,15 @@ 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 = "94:11:02:66:16:4D" # your D101 — change in the app, it is remembered DEFAULT_ADDR = "" # printer Bluetooth MAC / serial port; remembered once entered
def _asset_dir() -> Path: def _asset_dir() -> Path:
@@ -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 = []
+50 -28
View File
@@ -1,36 +1,54 @@
# `niimprint` &mdash; Niimbot Printer Client + Label Designer # Niimbot Label Designer &mdash; `niimprint`
Print to Niimbot label printers (B1, B18, B21, D11, D101, D110) over Bluetooth or USB, from a CLI or a desktop label designer with full emoji support. Design and print labels on Niimbot thermal label printers (B1, B18, B21, D11, D101, D110) over Bluetooth or USB. Includes a desktop **label designer** with drag-and-drop text and ~1,900 emoji, and the `niimprint` command-line client / Python library it is built on.
![Label designer, light theme](docs/app-light.png) ![Label designer, light theme](docs/app-light.png)
Fork of [AndBondStyle/niimprint](https://github.com/AndBondStyle/niimprint). Changes in this fork: ## Download
- **Label designer app** (`label_app.py`): drag-and-drop text and emoji, rotation, live 1-bit preview, light/dark themes Standalone builds, no Python needed — grab the latest from the [releases page](https://git.gracious.one/gracious/NIIMBOT-D101/releases):
| File | Platform |
|---|---|
| `NiimbotLabel.exe` | Windows 10/11 x64 |
| `NiimbotLabel` | Linux x64 (needs a system Tcl/Tk) |
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
| Model | Max print width | Density | Notes |
|---|---|---|---|
| B1, B21 | 384 px (50 mm tape) | 15 | |
| B18 | 384 px | 13 | |
| D11, D110 | 96 px (12 mm tape) | 13 | |
| D101 | 192 px (25 mm tape) | 13 | tested on 12 mm tape, firmware 20.51 |
Printers print at 8 px/mm (~203 dpi). Add a model in `niimprint/models.py` if yours is missing.
## About this fork
Fork of [AndBondStyle/niimprint](https://github.com/AndBondStyle/niimprint) (itself a fork of [kjy00302/niimprint](https://github.com/kjy00302/niimprint)). Changes here:
- **Label designer app** (`label_app.py`): free placement, rotation, live 1-bit preview, full emoji set, light/dark themes, standalone builds
- Printer model table (`niimprint/models.py`) with per-model width / density / protocol quirks; adds **D101** - Printer model table (`niimprint/models.py`) with per-model width / density / protocol quirks; adds **D101**
- Ported upstream PRs [#28](https://github.com/AndBondStyle/niimprint/pull/28) and [#12](https://github.com/AndBondStyle/niimprint/pull/12): D-series `ALLOW_PRINT_CLEAR`/`SET_QUANTITY` (fixes tall labels), print-status polling instead of a fixed sleep - Ported upstream PRs [#28](https://github.com/AndBondStyle/niimprint/pull/28) and [#12](https://github.com/AndBondStyle/niimprint/pull/12): D-series `ALLOW_PRINT_CLEAR` / `SET_QUANTITY` (fixes labels taller than ~210 px), print-status polling instead of a fixed sleep
- `PrinterError` raised on timeouts / rejected commands instead of returning `None` - `PrinterError` raised on timeouts / rejected commands instead of returning `None`
- Tested on a D101 (firmware 20.51) over Bluetooth
Upstream's own changelog vs. the original project: ## Running from source
- Tested on Niimbot B1, B18, B21, D11, D110 and Python 3.11
- Added transport abstraction: switch between bluetooth and USB (serial)
- Disabled checksum calculation for image encoding (works fine without it so far)
- Switched to [click](https://click.palletsprojects.com/) CLI library instead of argparse
- Integrated [pyproject.toml](https://pip.pypa.io/en/stable/reference/build-system/pyproject-toml/) and [poetry](https://python-poetry.org)
- Integrated [pre-commit](https://pre-commit.com/) and [ruff](https://docs.astral.sh/ruff/), re-formatted all files
## Installation
``` ```
git clone https://git.gracious.one/gracious_admin/NIIMBOT-D101.git git clone https://git.gracious.one/gracious/NIIMBOT-D101.git
cd NIIMBOT-D101 cd NIIMBOT-D101
python -m venv --system-site-packages .venv # system Python gives you tkinter python -m venv --system-site-packages .venv # system Python gives you tkinter
.venv/bin/pip install -r requirements.txt .venv/bin/pip install -r requirements.txt
``` ```
Or with [poetry](https://python-poetry.org): `poetry install`. Python 3.10+ (uses `match`); tested on 3.13. Or with [poetry](https://python-poetry.org): `poetry install`. Python 3.10+; tested on 3.13.
Then `python label_app.py` for the designer, or `python -m niimprint --help` for the CLI.
The label designer needs two fonts, both standard on most distros: The label designer needs two fonts, both standard on most distros:
@@ -39,10 +57,10 @@ The label designer needs two fonts, both standard on most distros:
Edit `TEXT_FONTS` / `EMOJI_FONT` at the top of `label_app.py` if yours live elsewhere. Edit `TEXT_FONTS` / `EMOJI_FONT` at the top of `label_app.py` if yours live elsewhere.
## Usage ## Command-line client
``` ```
$ python niimprint --help $ python -m niimprint --help
Usage: niimprint [OPTIONS] Usage: niimprint [OPTIONS]
@@ -57,7 +75,7 @@ Options:
--help Show this message and exit. --help Show this message and exit.
``` ```
### Image orientation: ### Image orientation
Generally, the image comes out of the printer with the same orientation you see it on your screen. You can have your input image rotated as you like, but adjust its orientation by passing `-r <...>` flag. See the image below for clarification. Generally, the image comes out of the printer with the same orientation you see it on your screen. You can have your input image rotated as you like, but adjust its orientation by passing `-r <...>` flag. See the image below for clarification.
@@ -65,7 +83,7 @@ Generally, the image comes out of the printer with the same orientation you see
<!-- Excalidraw link: https://excalidraw.com/#json=vYHMBohMn5GeB-5M6SNch,TsxRmh_WKUfzYjL183FGfg --> <!-- Excalidraw link: https://excalidraw.com/#json=vYHMBohMn5GeB-5M6SNch,TsxRmh_WKUfzYjL183FGfg -->
### Image resolution: ### Image resolution
As far as we've tested, Niimbot printers have **8 pixels per mm** (~203 dpi) resolution. The CLI prints the image you provided as-is, without any checks of the actual label size, so be careful. However the script will check if the image width is too big for selected printer. The maximum width in pixels is usually slightly less than specified maximum width in mm: As far as we've tested, Niimbot printers have **8 pixels per mm** (~203 dpi) resolution. The CLI prints the image you provided as-is, without any checks of the actual label size, so be careful. However the script will check if the image width is too big for selected printer. The maximum width in pixels is usually slightly less than specified maximum width in mm:
@@ -73,17 +91,17 @@ As far as we've tested, Niimbot printers have **8 pixels per mm** (~203 dpi) res
- **D11, D110**: max 96 pixels (almost equal to 15 mm * 8 px/mm = 120) - **D11, D110**: max 96 pixels (almost equal to 15 mm * 8 px/mm = 120)
- **D101**: max 192 pixels (25 mm tape); tested on 12 mm tape - **D101**: max 192 pixels (25 mm tape); tested on 12 mm tape
### USB connection: ### USB connection
For USB connection, you can omit the `--addr` argument and let the script auto-detect the serial port. However, it will fail if there're multiple available ports. On linux, serial ports can be found at `/dev/ttyUSB*`, `/dev/ttyACM*` or `/dev/serial/*`. On windows, they will be named like `COM1`, `COM2` etc. Check the device manager to choose the correct one. For USB connection, you can omit the `--addr` argument and let the script auto-detect the serial port. However, it will fail if there're multiple available ports. On linux, serial ports can be found at `/dev/ttyUSB*`, `/dev/ttyACM*` or `/dev/serial/*`. On windows, they will be named like `COM1`, `COM2` etc. Check the device manager to choose the correct one.
### Bluetooth connection: ### Bluetooth connection
It seems like B21 and B1 (and maybe other models?) have two bluetooth adresses. They have the same last 3 bytes, but the first 3 are rotated (for example `AA:BB:CC:DD:EE:FF` and `CC:AA:BB:DD:EE:FF`). Connection works only if you disconnect from one and connect to the other. After connecting via bluetoothctl you may get `org.bluez.Error.NotAvailable br-connection-profile-unavailable` error, but printing works fine regardless. It seems like B21 and B1 (and maybe other models?) have two bluetooth adresses. They have the same last 3 bytes, but the first 3 are rotated (for example `AA:BB:CC:DD:EE:FF` and `CC:AA:BB:DD:EE:FF`). Connection works only if you disconnect from one and connect to the other. After connecting via bluetoothctl you may get `org.bluez.Error.NotAvailable br-connection-profile-unavailable` error, but printing works fine regardless.
To identify which address is the correct one, run `bluetoothctl info` on the address you want to check. The incorrect one might list `UUID: Generic Access Profile` and `UUID: Generic Attribute Profile`, while the correct one will list `UUID: Serial Port`. To identify which address is the correct one, run `bluetoothctl info` on the address you want to check. The incorrect one might list `UUID: Generic Access Profile` and `UUID: Generic Attribute Profile`, while the correct one will list `UUID: Serial Port`.
The D101 does the same: it advertises as `D101-<serial>` on two addresses (e.g. `94:11:02:…` and `11:02:94:…`); use the one whose `bluetoothctl info` lists `UUID: Serial Port`, then `pair` and `trust` it once. The D101 does the same: it advertises as `D101-<serial>` on two addresses (e.g. `AA:BB:CC:…` and `BB:CC:AA:…`); use the one whose `bluetoothctl info` lists `UUID: Serial Port`, then `pair` and `trust` it once.
## Examples ## Examples
@@ -130,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
@@ -143,7 +163,7 @@ from niimprint import BluetoothTransport, PrinterClient, prepare_print
image = Image.open("label.png") # landscape, 8 px/mm image = Image.open("label.png") # landscape, 8 px/mm
image, density = prepare_print("d101", image, 3, rotate=90) image, density = prepare_print("d101", image, 3, rotate=90)
printer = PrinterClient(BluetoothTransport("94:11:02:66:16:4D")) printer = PrinterClient(BluetoothTransport("AA:BB:CC:DD:EE:FF"))
printer.print_image(image, density=density, model="d101") printer.print_image(image, density=density, model="d101")
``` ```
@@ -159,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`.
@@ -172,4 +192,6 @@ produces `dist\NiimbotLabel.exe` (~30 MB, no installation needed). Bluetooth use
## Licence ## Licence
[MIT](https://choosealicense.com/licenses/mit/). Originally developed by [kjy00302](https://github.com/kjy00302), forked & enhanced by [AndBondStyle](https://github.com/AndBondStyle) [MIT](https://choosealicense.com/licenses/mit/). Originally developed by [kjy00302](https://github.com/kjy00302), extended by [AndBondStyle](https://github.com/AndBondStyle); label designer and D101 support added in this fork.
Bundled third-party assets: [DejaVu fonts](https://dejavu-fonts.github.io/) (Bitstream Vera licence), [Noto Color Emoji](https://github.com/googlefonts/noto-emoji) (SIL OFL 1.1), [FriBidi](https://github.com/fribidi/fribidi) (LGPL 2.1, Windows build only) — see `assets/`. Emoji metadata from [emoji-data-python](https://github.com/alexmick/emoji-data-python); UI theme by [ttkbootstrap](https://ttkbootstrap.readthedocs.io).