thermoprint 24/7 Print Appliance — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Self-host thermoprint on telep-mainframe so a Marklife P15 BLE printer prints 24/7, driven from thermoprint’s own web editor hosted on the tailnet.
Architecture: A Bun “print service” on the mainframe reuses thermoprint’s @thermoprint/core + the CLI’s Noble BLE transport to print a PNG to the P15 (connect-per-job, serialized). The service ALSO serves the built web editor as static files (same origin → no CORS). thermoprint’s editor is patched so its Print button POSTs the rendered label PNG + options to /api/print instead of using Web Bluetooth. One tailscale serve mapping exposes it on the tailnet + landing page.
Tech Stack: Bun, TypeScript, @thermoprint/core (workspace), @stoprocent/noble, sharp, Vite/React (editor), systemd, tailscale serve.
Design spec: 2026-07-31-thermoprint-appliance-spec. Related: telep-mainframe, homelab.
Global Constraints
- Host: telep-mainframe (
levander@telep-mainframe,/usr/bin/sshonly). App dir:/home/levander/thermoprint. - Printer: Marklife P15, BLE name prefix one of
P15/LP15/S15/...(auto-recognized byfindDeviceByName). Profile defaults: density2, paper"gap". - BLE adapter:
hci0(Intel AX210). Noble needs raw HCI — grant caps to the bun binary (setcap cap_net_raw,cap_net_admin+eip) or systemdAmbientCapabilities. - Noble is a process-global singleton: scan+connect must be same-process; serialize all prints (mutex). No connect-by-id —
discoverAlleach job, match P15 by name. - Service listens
127.0.0.1:8095. Editor served at/, API at/api/*(same origin). Vitebase→'/'. - Server prints the received PNG as-is: no rotate, no resize (editor already rotates + sizes).
- House rules: no code comments; never commit unless asked; never Sonnet; subagent-driven.
Task 1: Provision mainframe + prove BLE discovery of the P15 (de-risk gate)
BLOCKED 2026-07-31 — AX210 has NO ANTENNA (missing RF path)
Steps 1–3 are DONE (Bun 1.3.14, repo cloned,
bun installclean, setcap granted). Step 4 (discovery) is blocked: the mainframe’s onboard Intel AX210 saw zero BLE devices (not even stray phones) because no antenna is plugged into the AX210 M.2 card (WiFi+BT share the u.FL antenna). Not a firmware fault — OS-side fixes were an ineffective detour. Fix = plug the antenna into the AX210 u.FL connector (USB BT dongle →hci1only as fallback), then re-run discovery. Full detail: 2026-07-31-telep-mainframe-ax210-ble-scan-broken.
Rationale: Everything depends on the mainframe’s Bluetooth actually seeing the P15. Prove it with the stock CLI before building anything. If this fails, STOP and report.
Files: none created (uses upstream repo).
- Step 1: Install Bun on the mainframe:
Run:
/usr/bin/ssh levander@telep-mainframe 'curl -fsSL https://bun.sh/install | bash'Expected: bun installed at~/.bun/bin/bun. - Step 2: Clone thermoprint + install deps:
Run:
/usr/bin/ssh levander@telep-mainframe 'git clone https://github.com/tomLadder/thermoprint.git /home/levander/thermoprint && cd /home/levander/thermoprint && ~/.bun/bin/bun install'Expected:node_modulespopulated, no fatal errors (sharp + @stoprocent/noble build). - Step 3: Grant BLE caps to bun (Noble raw HCI):
Run:
/usr/bin/ssh levander@telep-mainframe 'sudo setcap cap_net_raw,cap_net_admin+eip $(readlink -f ~/.bun/bin/bun)'Expected: no output (success). - Step 4: With the P15 powered on and in range, run discovery:
Run:
/usr/bin/ssh levander@telep-mainframe 'cd /home/levander/thermoprint && ~/.bun/bin/bun run packages/cli/src/index.ts discover'Expected: lists a printer whose name starts withP15/LP15/etc. If none found → STOP, report (printer asleep/out of range, or adapter contention with camwall’s BT usage). - Step 5: Optional end-to-end sanity — print a test image via stock CLI:
Run:
/usr/bin/ssh levander@telep-mainframe 'cd /home/levander/thermoprint && ~/.bun/bin/bun run packages/cli/src/index.ts print <some-test.png> --printer <P15name>'Expected: a physical label prints. Confirms the full core+Noble+P15 path before we wrap it.
Task 2: Print-service package — POST /api/print + GET /status + static editor
Files:
- Create:
/home/levander/thermoprint/packages/print-service/package.json - Create:
/home/levander/thermoprint/packages/print-service/src/server.ts - Create:
/home/levander/thermoprint/packages/print-service/src/print-queue.ts - Test:
/home/levander/thermoprint/packages/print-service/src/print-queue.test.ts
Interfaces:
-
Consumes (from
@thermoprint/core):Printer.connect(transport, peripheral),discoverAll(transport, {timeoutMs}),findDeviceByName(name), typePrintOptions = { density?, paperType?: "gap"|"continuous", copies?, dither?: "floyd-steinberg"|"threshold"|"none", threshold? }, typeRawImageData = { data: Uint8Array, width, height }. -
Consumes (from cli package):
NobleBleTransport(../../cli/src/transport/noble.ts), depends on@stoprocent/noble,sharp. -
Produces: HTTP
POST /api/print(multipart:imagePNG +optionsJSON) → prints;GET /api/status→{ ok, printer, lastResult };GET /*→ static editor frompackages/web/dist. -
Step 1: Write the failing test (
print-queue.test.ts) — the serialization guarantee (the one non-trivial logic worth a test):
import { test, expect } from "bun:test";
import { createQueue } from "./print-queue";
test("queue runs jobs strictly one at a time", async () => {
const q = createQueue();
const order: string[] = [];
let active = 0, maxActive = 0;
const job = (id: string) => async () => {
active++; maxActive = Math.max(maxActive, active);
await new Promise((r) => setTimeout(r, 20));
order.push(id); active--;
};
await Promise.all([q.run(job("a")), q.run(job("b")), q.run(job("c"))]);
expect(maxActive).toBe(1);
expect(order).toEqual(["a", "b", "c"]);
});-
Step 2: Run test to verify it fails Run:
cd /home/levander/thermoprint && ~/.bun/bin/bun test packages/print-service/src/print-queue.test.tsExpected: FAIL (“Cannot find module ‘./print-queue’”). -
Step 3: Implement the queue (
print-queue.ts):
export function createQueue() {
let tail: Promise<unknown> = Promise.resolve();
function run<T>(fn: () => Promise<T>): Promise<T> {
const result = tail.then(fn, fn);
tail = result.catch(() => {});
return result;
}
return { run };
}-
Step 4: Run test to verify it passes Run:
cd /home/levander/thermoprint && ~/.bun/bin/bun test packages/print-service/src/print-queue.test.tsExpected: PASS. -
Step 5: Write
package.json:
{
"name": "@thermoprint/print-service",
"type": "module",
"scripts": { "start": "bun run src/server.ts" },
"dependencies": {
"@thermoprint/core": "workspace:*",
"@stoprocent/noble": "^1.15.0",
"sharp": "^0.33.0"
}
}- Step 6: Write
server.ts— the print path mirrorscli/src/cli/commands/print.ts, but decodes bytes (not a file) and does NOT rotate/resize:
import { Printer, discoverAll, findDeviceByName } from "@thermoprint/core";
import type { PrintOptions, RawImageData } from "@thermoprint/core";
import { NobleBleTransport } from "../../cli/src/transport/noble.js";
import sharp from "sharp";
import { createQueue } from "./print-queue";
const PORT = 8095;
const PRINTER_NAME = process.env.TP_PRINTER;
const WEB_DIST = new URL("../../web/dist/", import.meta.url).pathname;
const queue = createQueue();
let lastResult = "none";
async function decodePng(buf: Uint8Array): Promise<RawImageData> {
const { data, info } = await sharp(buf).ensureAlpha().raw()
.toBuffer({ resolveWithObject: true });
return { data: new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
width: info.width, height: info.height };
}
async function printOnce(png: Uint8Array, opts: PrintOptions) {
const transport = new NobleBleTransport();
const peripherals = await discoverAll(transport, { timeoutMs: 8000 });
const match = PRINTER_NAME
? peripherals.find((p) => p.name.toLowerCase() === PRINTER_NAME.toLowerCase())
: peripherals.find((p) => findDeviceByName(p.name));
if (!match) throw new Error("printer not found");
const printer = await Printer.connect(transport, match);
try {
const image = await decodePng(png);
await printer.print(image, opts);
} finally {
await printer.disconnect();
}
}
Bun.serve({
port: PORT,
hostname: "127.0.0.1",
async fetch(req) {
const url = new URL(req.url);
if (url.pathname === "/api/status") {
return Response.json({ ok: true, printer: PRINTER_NAME ?? "auto", lastResult });
}
if (url.pathname === "/api/print" && req.method === "POST") {
const form = await req.formData();
const file = form.get("image") as File;
const opts = JSON.parse((form.get("options") as string) || "{}") as PrintOptions;
const png = new Uint8Array(await file.arrayBuffer());
try {
await queue.run(() => printOnce(png, opts));
lastResult = "ok " + new Date().toISOString();
return Response.json({ ok: true });
} catch (e) {
lastResult = "error: " + (e as Error).message;
return Response.json({ ok: false, error: (e as Error).message }, { status: 500 });
}
}
let p = url.pathname === "/" ? "/index.html" : url.pathname;
const f = Bun.file(WEB_DIST + p.replace(/^\//, ""));
if (await f.exists()) return new Response(f);
return new Response(Bun.file(WEB_DIST + "index.html"));
},
});
console.log("print-service on http://127.0.0.1:" + PORT);- Step 7: Typecheck (no BLE needed):
Run:
cd /home/levander/thermoprint && ~/.bun/bin/bun install && ~/.bun/bin/bun build packages/print-service/src/server.ts --target=bun >/dev/null && echo OKExpected:OK(imports resolve).
Task 3: Patch the editor — POST to the service instead of Web Bluetooth
Files:
- Modify:
/home/levander/thermoprint/packages/web/src/editor/editor.tsx(theprintcallback, ~lines 77–124) - Modify:
/home/levander/thermoprint/packages/web/src/editor/top-chrome/print-button.tsx(fire, theconnectedgate ~line 57) - Modify:
/home/levander/thermoprint/packages/web/vite.config.ts(base: '/')
Interfaces:
-
Consumes: the existing
captureLabel(stage,w,h)+rotateCanvas90CW(canvas)ineditor.tsx; storeuseEditorV2StorefieldsprintSettings.{density,ditherMode,threshold},paperType. -
Produces: a
print(copies)that returnstrueon HTTP 2xx. -
Step 1: In
vite.config.ts, setbase: '/'(served at root by the print-service). -
Step 2: Replace the body of the
printcallback ineditor.tsx(keepcaptureLabel+rotateCanvas90CW, dropgetPrinter()/printer.print):
const print = useCallback(async (copies: number): Promise<boolean> => {
const stage = stageRef.current;
if (!stage) return false;
const { label, printSettings, paperType } = useEditorV2Store.getState();
useEditorV2Store.getState().clearSelection();
await new Promise((r) => requestAnimationFrame(r));
const canvas = rotateCanvas90CW(captureLabel(stage, label.widthPx, label.heightPx));
const blob: Blob = await new Promise((res) => canvas.toBlob((b) => res(b!), "image/png"));
const form = new FormData();
form.append("image", blob, "label.png");
form.append("options", JSON.stringify({
density: printSettings.density,
paperType,
copies,
dither: printSettings.ditherMode,
threshold: printSettings.threshold,
}));
const resp = await fetch("/api/print", { method: "POST", body: form });
return resp.ok;
}, []);-
Step 3: In
print-button.tsx, relax the Web-Bluetooth gate so Print always callsonPrint(server owns the printer). Change the early-return infirethat opens the connect flow when!printer.connectedto proceed directly toawait onPrint(copies). (Simplest: remove theconnectedguard / the connect-flow branch.) -
Step 4: Build the editor: Run:
/usr/bin/ssh levander@telep-mainframe 'cd /home/levander/thermoprint && ~/.bun/bin/bunx --bun vite build packages/web 2>&1 | tail -5 || (cd packages/web && ~/.bun/bin/bun run build)'Expected:packages/web/dist/index.html+ assets produced. -
Step 5: Verify static serve (no BLE): start the service, curl the editor + status. Run:
/usr/bin/ssh levander@telep-mainframe 'cd /home/levander/thermoprint && TP_PRINTER="<P15name>" ~/.bun/bin/bun run packages/print-service/src/server.ts & sleep 2; curl -s -o /dev/null -w "root=%{http_code}\n" http://127.0.0.1:8095/; curl -s http://127.0.0.1:8095/api/status; kill %1'Expected:root=200and{"ok":true,...}.
Task 4: systemd service + tailscale serve + landing page
Files:
-
Create:
/etc/systemd/system/thermoprint.service(via sudo) -
Step 1: Write the unit:
[Unit]
Description=thermoprint print appliance (editor + BLE print service)
After=bluetooth.target network-online.target
[Service]
User=levander
WorkingDirectory=/home/levander/thermoprint
Environment=TP_PRINTER=<P15name>
ExecStart=/home/levander/.bun/bin/bun run packages/print-service/src/server.ts
AmbientCapabilities=CAP_NET_RAW CAP_NET_ADMIN
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target-
Step 2: Enable + start: Run:
/usr/bin/ssh levander@telep-mainframe 'sudo systemctl daemon-reload && sudo systemctl enable --now thermoprint.service && sleep 3 && systemctl is-active thermoprint.service'Expected:active. -
Step 3: tailscale serve mapping (HTTPS, tailnet-only) — pick an unused serve port (e.g. 8446, alongside existing 443/8443/8445): Run:
/usr/bin/ssh levander@telep-mainframe 'sudo tailscale serve --bg --https 8446 http://127.0.0.1:8095 && sudo tailscale serve status'Expected:https://telep-mainframe.taild4189d.ts.net:8446→http://127.0.0.1:8095. -
Step 4: Add to the tailscale landing page. Find the landing-page source (the page listing knowledgebase/frigate endpoints — likely another
tailscale servetarget or a static index) and add a link tohttps://telep-mainframe.taild4189d.ts.net:8446labeled “Thermoprint (label printer)“. Locate it first:grep -ril "knowledgebase\|frigate" /home/levander --include=*.html --include=*.md 2>/dev/nulland the router/mainframe serve configs.
Task 5: End-to-end verification
- Step 1: From a laptop/phone on the tailnet, open
https://telep-mainframe.taild4189d.ts.net:8446→ the thermoprint editor loads. - Step 2: Design a simple label (text + QR), set density/paper, click Print.
- Step 3: Confirm a physical label emerges from the P15. Check
GET /api/statusshowsok. - Step 4: Print twice in quick succession → both print, no BLE collision (queue works).
- Step 5:
sudo systemctl restart thermoprint→ editor + printing still work (survives reboot).
Self-review notes
- Spec coverage: print service (T2), hosted editor + patch (T3), tailscale + landing page (T4), 24/7 systemd (T4), BLE de-risk (T1), caveats (printer-awake noted in T1 step 4). All covered.
- Known open detail for execution: exact
<P15name>is discovered in T1 and substituted into T2/T4. Theprint-button.tsxguard edit is described, not coded verbatim — the executor reads the currentfirebody and removes theconnected-gated connect-flow branch.