// niimbot-server.mjs: NIIMBOT B1 に BLE でつなぎ、HTTP で印刷ジョブを受け付けるサーバー // // 準備: npm install @mmote/niimblue-node async-mutex qrcode sharp // 起動: NIIMBOT_ADDR="B1-<シリアル>" node niimbot-server.mjs // 試験: NIIMBOT_MOCK=1 node niimbot-server.mjs (印刷せず ./mock-output に PNG を書く) // // 環境変数: NIIMBOT_HOST(既定 0.0.0.0)/ NIIMBOT_PORT(既定 3838)/ NIIMBOT_IDLE_MS(既定 30000) // NIIMBOT_MAX_BODY(既定 10 MB)/ NIIMBOT_TRANSPORT(既定 ble)/ NIIMBOT_DEBUG=1 // エンドポイント: GET /health・GET /info・POST /print/text・POST /print/qr・POST /print/image // 認証はないので、宅内 LAN の外には公開しない。 import http from "node:http"; import { mkdirSync, writeFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import sharp from "sharp"; import { Mutex } from "async-mutex"; import { initClient, printImages, ImageEncoder } from "@mmote/niimblue-node"; import qrcode from "qrcode"; // ---- ラベル画像の生成(元は lib/label.mjs) ---- const PX_PER_MM = 8; const PRINTHEAD_MAX_PX = 384; const LABEL_MM = { w: 50, h: 30 }; const INSET_PX = { left: 0, right: 24, top: 12, bottom: 12 }; const CANVAS_W = Math.min(LABEL_MM.w * PX_PER_MM, PRINTHEAD_MAX_PX); const CANVAS_H = LABEL_MM.h * PX_PER_MM; const drawBox = () => { const bx = INSET_PX.left; const by = INSET_PX.top; const bw = CANVAS_W - INSET_PX.left - INSET_PX.right; const bh = CANVAS_H - INSET_PX.top - INSET_PX.bottom; return { bx, by, bw, bh }; }; const escapeXml = (s) => String(s).replace(/[<>&'"]/g, (c) => ({ "<": "<", ">": ">", "&": "&", "'": "'", '"': """ })[c]); const renderTextLabelSvg = ({ title = "", subtitle = "" } = {}) => { const { bx, by, bw, bh } = drawBox(); const cx = bx + bw / 2; const hasSub = subtitle.length > 0; const titleSize = hasSub ? 64 : 84; const titleY = hasSub ? by + bh * 0.45 : by + bh * 0.62; return ` ${escapeXml(title)} ${hasSub ? `${escapeXml(subtitle)}` : ""} `; }; const generateTextLabel = async ({ title, subtitle = "" }) => { return sharp(Buffer.from(renderTextLabelSvg({ title, subtitle }))).png().toBuffer(); }; const generateQrLabel = async ({ url, caption = "", qrSize = 192 }) => { const { bx, by, bw, bh } = drawBox(); const qrX = bx + 4; const qrY = by + Math.round((bh - qrSize) / 2); const textX = qrX + qrSize + 12; const textW = bx + bw - textX; const qrSvg = await qrcode.toString(url, { type: "svg", margin: 0, errorCorrectionLevel: "M" }); const qrPngBuf = await sharp(Buffer.from(qrSvg)).resize(qrSize, qrSize, { kernel: "nearest" }).png().toBuffer(); const lines = String(caption).split(/\\n|\n/).map((s) => s.trim()).filter(Boolean); const lineH = lines.length > 0 ? Math.min(36, Math.floor((bh - 20) / lines.length)) : 0; const fontSize = Math.round(lineH * 0.75); const startY = by + Math.round((bh - lineH * lines.length) / 2) + Math.round(lineH * 0.7); const captionSvg = lines .map((line, i) => `${escapeXml(line)}`) .join("\n"); const bgSvg = ` ${captionSvg}`; return sharp(Buffer.from(bgSvg)).composite([{ input: qrPngBuf, left: qrX, top: qrY }]).png().toBuffer(); }; // ---- HTTP サーバー ---- const PORT = parseInt(process.env.NIIMBOT_PORT ?? "3838", 10); const HOST = process.env.NIIMBOT_HOST ?? "0.0.0.0"; const ADDR = process.env.NIIMBOT_ADDR ?? ""; const TRANSPORT = process.env.NIIMBOT_TRANSPORT ?? "ble"; const IDLE_DISCONNECT_MS = parseInt(process.env.NIIMBOT_IDLE_MS ?? "30000", 10); const MAX_BODY_BYTES = parseInt(process.env.NIIMBOT_MAX_BODY ?? String(10 * 1024 * 1024), 10); const DEBUG = process.env.NIIMBOT_DEBUG === "1"; const MOCK = process.env.NIIMBOT_MOCK === "1"; const MOCK_DIR = process.env.NIIMBOT_MOCK_DIR ?? path.resolve("./mock-output"); const runningAsMain = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); if (MOCK && runningAsMain) mkdirSync(MOCK_DIR, { recursive: true }); const bleLock = new Mutex(); let client = null; let idleTimer = null; const scheduleIdleDisconnect = () => { if (idleTimer) clearTimeout(idleTimer); idleTimer = setTimeout(() => { bleLock.runExclusive(async () => { if (client?.isConnected()) { console.log("[idle] disconnecting"); await client.disconnect().catch((e) => console.warn("disconnect err", e.message)); } client = null; }); }, IDLE_DISCONNECT_MS); }; const ensureConnected = async () => { if (client?.isConnected()) return client; console.log(`[connect] ${TRANSPORT} ${ADDR}`); client = initClient(TRANSPORT, ADDR, DEBUG); await client.connect(); return client; }; let mockSeq = 0; const printPng = async (pngBuffer, { quantity = 1, density = 3 } = {}) => { if (MOCK) { const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const filename = path.join(MOCK_DIR, `${stamp}-${String(++mockSeq).padStart(4, "0")}.png`); writeFileSync(filename, pngBuffer); console.log(`[mock] wrote ${filename} (${pngBuffer.length}B, qty=${quantity}, density=${density})`); return { mockPath: filename }; } return bleLock.runExclusive(async () => { const c = await ensureConnected(); const image = sharp(pngBuffer).flatten({ background: "#fff" }).threshold(128); const printDirection = c.getModelMetadata()?.printDirection; const printTask = c.getPrintTaskType(); if (!printTask) throw new Error("Print task not detected"); const encoded = await ImageEncoder.encodeImage(image, printDirection); await printImages(c, printTask, [{ encoded, quantity }], { quantity, density, labelType: 1 }); scheduleIdleDisconnect(); }); }; const httpErr = (status, message) => Object.assign(new Error(message), { httpStatus: status }); const readJson = (req, maxBytes = MAX_BODY_BYTES) => new Promise((resolve, reject) => { let size = 0; const chunks = []; req.on("data", (chunk) => { size += chunk.length; if (size > maxBytes) { reject(httpErr(413, `body exceeds ${maxBytes} bytes`)); req.destroy(); return; } chunks.push(chunk); }); req.on("end", () => { if (chunks.length === 0) return resolve({}); try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); } catch (e) { reject(httpErr(400, `invalid JSON: ${e.message}`)); } }); req.on("error", reject); }); const jsonResponse = (res, status, body) => { const buf = Buffer.from(JSON.stringify(body)); res.writeHead(status, { "Content-Type": "application/json", "Content-Length": buf.length }); res.end(buf); }; const intField = (value, name, min, max, defaultValue) => { if (value === undefined || value === null || value === "") return defaultValue; const n = Number(value); if (!Number.isInteger(n) || n < min || n > max) { throw httpErr(400, `${name} must be an integer between ${min} and ${max}`); } return n; }; const strField = (value, name, { required = false, maxLength = 500, defaultValue = "" } = {}) => { if (value === undefined || value === null || value === "") { if (required) throw httpErr(400, `${name} required`); return defaultValue; } if (typeof value !== "string") throw httpErr(400, `${name} must be a string`); if (value.length > maxLength) throw httpErr(400, `${name} must be at most ${maxLength} characters`); return value; }; const printOptions = (body) => ({ quantity: intField(body.quantity, "quantity", 1, 1000, 1), density: intField(body.density, "density", 1, 5, 3), }); const routes = { "GET /health": async () => ({ ok: true, addr: ADDR, transport: TRANSPORT, connected: !!client?.isConnected(), mock: MOCK, }), "GET /info": async () => { if (MOCK) { return { mock: true, modelMetadata: { model: "B1", dpi: 203, printDirection: "top", printheadPixels: PRINTHEAD_MAX_PX }, }; } return bleLock.runExclusive(async () => { const c = await ensureConnected(); const info = { printerInfo: c.getPrinterInfo(), modelMetadata: c.getModelMetadata(), printTask: c.getPrintTaskType(), }; scheduleIdleDisconnect(); return info; }); }, "POST /print/image": async (req) => { const body = await readJson(req); if (typeof body.imageBase64 !== "string") throw httpErr(400, "imageBase64 required"); const pngBuffer = Buffer.from(body.imageBase64, "base64"); if (pngBuffer.length === 0) throw httpErr(400, "imageBase64 is not valid base64"); const r = await printPng(pngBuffer, printOptions(body)); return { ok: true, ...(r ?? {}) }; }, "POST /print/text": async (req) => { const body = await readJson(req); const title = strField(body.title, "title", { required: true }); const subtitle = strField(body.subtitle, "subtitle"); const png = await generateTextLabel({ title, subtitle }); const r = await printPng(png, printOptions(body)); return { ok: true, ...(r ?? {}) }; }, "POST /print/qr": async (req) => { const body = await readJson(req); const url = strField(body.url, "url", { required: true }); const caption = strField(body.caption, "caption"); const png = await generateQrLabel({ url, caption, qrSize: intField(body.qrSize, "qrSize", 32, PRINTHEAD_MAX_PX, 192), }); const r = await printPng(png, printOptions(body)); return { ok: true, ...(r ?? {}) }; }, }; const server = http.createServer(async (req, res) => { const key = `${req.method} ${req.url?.split("?")[0]}`; const handler = routes[key]; const started = Date.now(); if (!handler) { return jsonResponse(res, 404, { error: `Not found: ${key}`, endpoints: Object.keys(routes) }); } try { const result = await handler(req); console.log(`${key} -> 200 (${Date.now() - started}ms)`); jsonResponse(res, 200, result); } catch (e) { const status = e.httpStatus ?? 500; console.warn(`${key} -> ${status}: ${e.message}`); if (!res.writableEnded && !res.destroyed) jsonResponse(res, status, { error: e.message }); } }); if (runningAsMain) { if (!ADDR && !MOCK) { console.error("NIIMBOT_ADDR にプリンターの BLE 名(例: B1-<シリアル>)を指定してください"); process.exit(1); } server.listen(PORT, HOST, () => { console.log(`niimbot-print listening on http://${HOST}:${PORT}`); console.log(` addr=${ADDR} transport=${TRANSPORT} idle=${IDLE_DISCONNECT_MS}ms canvas=${CANVAS_W}x${CANVAS_H}`); if (MOCK) console.log(` MOCK mode: writing to ${MOCK_DIR}`); console.log(` endpoints: ${Object.keys(routes).join(", ")}`); }); const shutdown = async (signal) => { console.log(`[${signal}] shutting down`); server.close(); if (idleTimer) clearTimeout(idleTimer); if (client?.isConnected()) await client.disconnect().catch(() => {}); process.exit(0); }; process.on("SIGINT", () => shutdown("SIGINT")); process.on("SIGTERM", () => shutdown("SIGTERM")); }