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/ssh only). App dir: /home/levander/thermoprint.
  • Printer: Marklife P15, BLE name prefix one of P15/LP15/S15/... (auto-recognized by findDeviceByName). Profile defaults: density 2, 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 systemd AmbientCapabilities.
  • Noble is a process-global singleton: scan+connect must be same-process; serialize all prints (mutex). No connect-by-id — discoverAll each job, match P15 by name.
  • Service listens 127.0.0.1:8095. Editor served at /, API at /api/* (same origin). Vite base'/'.
  • 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 install clean, 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 → hci1 only 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_modules populated, 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 with P15/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), type PrintOptions = { density?, paperType?: "gap"|"continuous", copies?, dither?: "floyd-steinberg"|"threshold"|"none", threshold? }, type RawImageData = { 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: image PNG + options JSON) → prints; GET /api/status{ ok, printer, lastResult }; GET /* → static editor from packages/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.ts Expected: 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.ts Expected: 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 mirrors cli/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 OK Expected: 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 (the print callback, ~lines 77–124)
  • Modify: /home/levander/thermoprint/packages/web/src/editor/top-chrome/print-button.tsx (fire, the connected gate ~line 57)
  • Modify: /home/levander/thermoprint/packages/web/vite.config.ts (base: '/')

Interfaces:

  • Consumes: the existing captureLabel(stage,w,h) + rotateCanvas90CW(canvas) in editor.tsx; store useEditorV2Store fields printSettings.{density,ditherMode,threshold}, paperType.

  • Produces: a print(copies) that returns true on HTTP 2xx.

  • Step 1: In vite.config.ts, set base: '/' (served at root by the print-service).

  • Step 2: Replace the body of the print callback in editor.tsx (keep captureLabel + rotateCanvas90CW, drop getPrinter()/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 calls onPrint (server owns the printer). Change the early-return in fire that opens the connect flow when !printer.connected to proceed directly to await onPrint(copies). (Simplest: remove the connected guard / 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=200 and {"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:8446http://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 serve target or a static index) and add a link to https://telep-mainframe.taild4189d.ts.net:8446 labeled “Thermoprint (label printer)“. Locate it first: grep -ril "knowledgebase\|frigate" /home/levander --include=*.html --include=*.md 2>/dev/null and 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/status shows ok.
  • 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. The print-button.tsx guard edit is described, not coded verbatim — the executor reads the current fire body and removes the connected-gated connect-flow branch.