// 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 ``;
};
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 = ``;
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"));
}