1// Niimbot label printer â binary packet protocol + BLE transport. 2// 3// Sibling of nelko-p21.js and deliberately exposes the SAME public surface 4// (TAPES / tapeGeometry / packRaster / encodeCanvas / isAvailable / Printer), 5// so app.html's Print Labels sheet can drive either printer through one 6// indirection and the label renderer, design panel and filters are shared. 7// 8// It is NOT a variant of the Nelko driver though â almost nothing is shared 9// below the surface: 10// 11// Nelko P21 Niimbot D11 12// --------- ----------- 13// TSPL2 text commands, CRLF binary packets 55 55 <type> <len> <data> <xor> AA AA 14// bit 0 prints BLACK bit 1 prints BLACK 15// separate write + notify ONE characteristic, notify + write-no-response 16// never answers anything answers every command; real status polling 17// blind 4.3s gap per label poll the page counter, then send the next page 18// 19// The status channel is the big win. The P21's multi-label bug took five wrong 20// theories to solve precisely because the printer says nothing (see 21// nelko-p21.js printBatch / feedMs). Niimbot acks every setup command and 22// reports a page counter, so this driver waits on the printer rather than on a 23// guessed delay. 24// 25// Protocol sources (2026-08-11): AndBondStyle/niimprint (printer.py, packet.py) 26// and MultiMote/niimbluelib (packet_generator.ts, utils.ts, B1PrintTask.ts), 27// cross-checked against the NIIMBOT community wiki at printers.niim.blue. 28// 29// Confirmed printing on a real D110_M, 2026-08-12 â including the polarity, 30// which those sources had right all along (see BLACK_BIT). 31var Niimbot = (function () { 32 'use strict'; 33 34 // Single characteristic carrying both directions: NOTIFY + WRITE_NO_RESPONSE. 35 // (The Nelko splits these across FF02/FF01; do not carry that assumption over.) 36 var GATT = { 37 service: 'e7810a71-73ae-499d-8c15-faa9aef0c3f2', 38 io: 'bef8d6c9-9c21-4c9e-b632-bd58c1009f9f', 39 }; 40 41 var DPI = 203; 42 43 // Which bit value marks a black dot: ONE. Opposite of TSPL on the Nelko, and 44 // what niimprint and niimbluelib both say. 45 // 46 // This was briefly set to 0 on the strength of a single solid-black test read 47 // during the roll-flip, and that was wrong: real labels printed as photo 48 // negatives until it went back. The measurement that settles it is the clean 49 // one â app deleted and reinstalled, so no cached driver, 0-is-black packing, 50 // and ink landed on the BACKGROUND. Ink follows the 1 bits. 51 // 52 // Exported so the sheet's preview decodes with the polarity it encoded. 53 var BLACK_BIT = 1; 54 55 function mmToDots(mm) { 56 return Math.round((mm / 25.4) * DPI); 57 } 58 59 // ââ Models ââ 60 // 61 // `head` is the printhead width in dots and is the hard ceiling on printable 62 // width regardless of how wide the label stock is: a D11 takes 15mm labels 63 // but can only put ink across 12mm of them. 64 // 65 // Order matters â matched by longest name prefix first, so a "D110-A1B2" 66 // advertisement doesn't get claimed by the D11 entry. 67 var MODELS = [ 68 // D110_M advertises as "D110_M-<serial>" and MUST be listed before D110 or 69 // the substring match hands it to the wrong entry. 70 // 71 // Confirmed against the model's own wiki page: 203 DPI, 12mm head = 96px, 72 // model id 2320 â and sub 0x08 on the real unit answered 0x0910 = 2320. 73 // Density range is 1-5 with 3 the factory default. 74 // 75 // task 'v4' â READ OFF THE OFFICIAL APP'S OWN TRAFFIC (btsnoop capture, 76 // 2026-08-13), which is the only source here that is not a guess: 77 // 78 // SetLabelType [01] 79 // SetDensity [03] 80 // PrintStart [00 01 00 00 00 00 00 01 00] <- 9 bytes 81 // SetPageSize [01 40 00 60 00 01 00*7] <- 13 bytes, 82 // rows 320, cols 96, copies 1 INLINE 83 // rows, PageEnd [01], PrintEnd [01] 84 // 85 // No PageStart. No PrintClear. No PrintQuantity. Copies live in 86 // setPageSize, exactly where the d110 path insisted this firmware never 87 // reads them. 88 // 89 // v4 was tried early and dismissed because "the page counter never moves" 90 // â true, and caused by our v4 omitting PageEnd, so no page ever finished. 91 // The d110 task that replaced it does print, but only from page two on. 92 // def 5. Raised from the factory 3 while chasing blank labels, on a theory 93 // (density 3 cannot render fine detail) that the real cause â the wrong 94 // print task, with rows sent unpaced â later made unnecessary. It has never 95 // been retested at 3 now that printing works, so it stays: it is the value 96 // every successful print has used. Drop it to 3 if labels ever look 97 // over-dark or bleed, and expect that to be fine. 98 { id: 'D110_M', head: 96, density: { min: 1, max: 5, def: 5 }, task: 'v4' }, 99 { id: 'D110', head: 96, density: { min: 1, max: 3, def: 3 }, task: 'd110' }, 100 { id: 'D11', head: 96, density: { min: 1, max: 3, def: 3 }, task: 'd110' }, 101 { id: 'B18', head: 120, density: { min: 1, max: 3, def: 3 } }, 102 { id: 'B203', head: 384, density: { min: 1, max: 5, def: 3 } }, 103 { id: 'B21', head: 384, density: { min: 1, max: 5, def: 3 } }, 104 { id: 'B1', head: 384, density: { min: 1, max: 5, def: 3 } }, 105 ]; 106 107 // Looked up by id, not by index â this list has grown once already and an 108 // index would silently re-point the fallback at whatever got inserted. 109 var DEFAULT_MODEL = MODELS.filter(function (m) { return m.id === 'D11'; })[0]; 110 111 function modelFor(name) { 112 var n = (name || '').toUpperCase(); 113 for (var i = 0; i < MODELS.length; i++) { 114 if (n.indexOf(MODELS[i].id) >= 0) return MODELS[i]; 115 } 116 return null; 117 } 118 119 // Printhead width used to size the raster. Set from the model on connect; 120 // until then it is the D11's 96, which is also what the tape presets assume. 121 var activeHead = DEFAULT_MODEL.head; 122 123 // ââ Label stock ââ 124 // 125 // Niimbot labels are die-cut with gaps (SetLabelType 1), not continuous tape, 126 // so unlike the P21 the length is fixed by the stock rather than chosen. Keys 127 // match the Nelko's "WxL" shape so the sheet's tape <select> is interchangeable. 128 // 129 // 12x40 leads because it is the standard D11 roll AND it is dot-for-dot the 130 // P21's default (96 x 320), which means a label designed on one prints 131 // identically on the other. 132 var TAPES = { 133 '12x40': { tapeW: 12, tapeL: 40 }, 134 '12x30': { tapeW: 12, tapeL: 30 }, 135 '12x22': { tapeW: 12, tapeL: 22 }, 136 '14x28': { tapeW: 14, tapeL: 28 }, 137 '15x30': { tapeW: 15, tapeL: 30 }, 138 '15x50': { tapeW: 15, tapeL: 50 }, 139 }; 140 141 // Same return shape as NelkoP21.tapeGeometry so _p21RenderLabel and 142 // _p21PreviewCanvas need no per-driver branching. 143 // 144 // widthDots is clamped to the printhead: asking for 15mm of dots on a 96-dot 145 // head would build a raster wider than the printer can accept, and the row 146 // packets would be silently truncated or rejected. 147 function tapeGeometry(key) { 148 var t = TAPES[key]; 149 if (!t) throw new Error('Unknown label size: ' + key); 150 return { 151 tapeW: t.tapeW, 152 tapeL: t.tapeL, 153 widthDots: Math.min(mmToDots(t.tapeW), activeHead), 154 heightDots: mmToDots(t.tapeL), 155 }; 156 } 157 158 // ââ Raster packing ââ 159 // 160 // Twin of NelkoP21.packRaster with the polarity flipped: here a 1 bit prints 161 // black, so we start from all-zero (blank) and SET bits to draw. Kept as its 162 // own copy rather than sharing, because nelko-p21.js is device-verified and 163 // hard-won; a shared helper would put both printers on one edit. 164 // 165 // Dithering is not polish. A flat 50% gray prints as 0% coverage under a hard 166 // threshold â verified on the P21 and true of any 1-bit thermal head. Profile 167 // photos are the whole reason this exists. 168 function packRaster(rgba, widthDots, heightDots, opts) { 169 if (typeof opts === 'number') opts = { threshold: opts }; 170 opts = opts || {}; 171 var threshold = opts.threshold == null ? 128 : opts.threshold; 172 var dither = opts.dither !== false; 173 174 var widthBytes = Math.ceil(widthDots / 8); 175 var out = new Uint8Array(widthBytes * heightDots); // zero = blank 176 // opts.invert flips the finished raster. Kept as an escape hatch, and as 177 // the thing that proved the default: with it on, every label came out as a 178 // photo negative. 179 180 // Grayscale plane first, in float, so error diffusion has somewhere to 181 // accumulate without clamping bias. 182 var gray = new Float32Array(widthDots * heightDots); 183 for (var p = 0; p < widthDots * heightDots; p++) { 184 var i = p * 4; 185 // Composite against white so transparent reads as blank, not black. 186 var a = rgba[i + 3] / 255; 187 var r = rgba[i] * a + 255 * (1 - a); 188 var g = rgba[i + 1] * a + 255 * (1 - a); 189 var b = rgba[i + 2] * a + 255 * (1 - a); 190 gray[p] = 0.299 * r + 0.587 * g + 0.114 * b; 191 } 192 193 for (var y = 0; y < heightDots; y++) { 194 var rowStart = y * widthBytes; 195 for (var x = 0; x < widthDots; x++) { 196 var idx = y * widthDots + x; 197 var old = gray[idx]; 198 var isBlack = old < threshold; 199 if (isBlack) out[rowStart + (x >> 3)] |= (0x80 >> (x & 7)); 200 201 if (!dither) continue; 202 var err = old - (isBlack ? 0 : 255); 203 // Floyd-Steinberg: 7/16 right, 3/16 below-left, 5/16 below, 1/16 below-right. 204 if (x + 1 < widthDots) gray[idx + 1] += err * 7 / 16; 205 if (y + 1 < heightDots) { 206 if (x > 0) gray[idx + widthDots - 1] += err * 3 / 16; 207 gray[idx + widthDots] += err * 5 / 16; 208 if (x + 1 < widthDots) gray[idx + widthDots + 1] += err * 1 / 16; 209 } 210 } 211 } 212 if (opts.invert) { 213 for (var k = 0; k < out.length; k++) out[k] = ~out[k] & 0xFF; 214 } 215 return out; 216 } 217 218 // ââ Packets ââ 219 // 220 // 55 55 <type> <len> <data...> <checksum> AA AA, checksum = type XOR len XOR 221 // every data byte. `len` is one byte, so a single packet carries at most 255 222 // bytes of payload â comfortably above a 384-dot row (48 bytes + 6 header). 223 function packet(type, data) { 224 data = data || []; 225 var n = data.length; 226 if (n > 255) throw new Error('Packet payload too long: ' + n); 227 var out = new Uint8Array(n + 7); 228 out[0] = 0x55; out[1] = 0x55; out[2] = type; out[3] = n; 229 var ck = type ^ n; 230 for (var i = 0; i < n; i++) { out[4 + i] = data[i] & 0xFF; ck ^= out[4 + i]; } 231 out[4 + n] = ck & 0xFF; 232 out[5 + n] = 0xAA; out[6 + n] = 0xAA; 233 return out; 234 } 235 236 function u16(n) { return [(n >> 8) & 0xFF, n & 0xFF]; } 237 238 // Incremental parser. BLE hands notifications back in MTU-sized fragments and 239 // a packet can straddle two of them (the community wiki calls this out 240 // explicitly), so bytes accumulate here until a whole frame is available. 241 function Parser() { 242 this.buf = []; 243 } 244 Parser.prototype.push = function (bytes) { 245 var found = []; 246 for (var i = 0; i < bytes.length; i++) this.buf.push(bytes[i] & 0xFF); 247 for (;;) { 248 // Resync: drop anything before a 55 55 header. 249 while (this.buf.length >= 2 && !(this.buf[0] === 0x55 && this.buf[1] === 0x55)) this.buf.shift(); 250 if (this.buf.length < 4) break; 251 var total = this.buf[3] + 7; 252 if (this.buf.length < total) break; 253 var frame = this.buf.slice(0, total); 254 this.buf = this.buf.slice(total); 255 var type = frame[2], len = frame[3]; 256 var data = frame.slice(4, 4 + len); 257 var ck = type ^ len; 258 for (var j = 0; j < data.length; j++) ck ^= data[j]; 259 if ((ck & 0xFF) === frame[4 + len]) { 260 found.push({ type: type, data: data }); 261 continue; 262 } 263 // Bad checksum. The likely cause is not a corrupted byte â BLE already 264 // CRCs the link â but that we resynced onto a 55 55 that was payload 265 // rather than a header, so `len` was never a real length. Consuming 266 // `total` on that reading would swallow the genuine packet that follows, 267 // so step past the false header instead and rescan. 268 this.buf = frame.slice(2).concat(this.buf); 269 } 270 return found; 271 }; 272 273 // ââ Request codes ââ 274 // 275 // Response codes are NOT uniformly request+1: SetDensity and SetLabelType 276 // answer at request+16. Each entry carries its own expected reply. 277 var CMD = { 278 printerStatusData: { req: 0xA5, res: 0xB5, data: [0x01] }, 279 heartbeat: { req: 0xDC, res: 0xD9, data: [0x04] }, 280 // `ack` marks commands whose reply's FIRST data byte is a success flag: 281 // 01 accepted, 00 rejected. This was ignored for this driver's whole 282 // history and it hid a real failure â a second print job started too soon 283 // after the first answered 00 to density, labelType and printStart, so the 284 // job never ran and the label simply never appeared. Every command looked 285 // "ok" in the log because the log printed the name, not the flag. 286 setDensity: { req: 0x21, res: 0x31, ack: true },
287 setLabelType: { req: 0x23, res: 0x33, ack: true }, 288 printStart: { req: 0x01, res: 0x02, ack: true }, 289 pageStart: { req: 0x03, res: 0x04, ack: true }, 290 setPageSize: { req: 0x13, res: 0x14, ack: true }, 291 // The two the D110 task needs and the others don't. printQuantity is the 292 // one that matters: on this task copies are their OWN command, not a field 293 // inside setPageSize, so omitting it leaves the copy count at zero â the 294 // page then runs to 100% with the head never heating. That is exactly the 295 // blank label device tests 3-5 produced. 296 printClear: { req: 0x20, res: 0x30, ack: true }, 297 printQuantity: { req: 0x15, res: 0x16, ack: true }, 298 pageEnd: { req: 0xE3, res: 0xE4, data: [0x01], ack: true }, 299 printEnd: { req: 0xF3, res: 0xF4, data: [0x01], ack: true }, 300 printStatus: { req: 0xA3, res: 0xB3, data: [0x01] }, 301 printerInfo: { req: 0x40 }, 302 }; 303 304 // Fire-and-forget image packets â the printer does not ack these. 305 var ROW_BLANK = 0x84; // [row_hi, row_lo, repeat] 306 var ROW_PIXELS = 0x85; // [row_hi, row_lo, c1, c2, c3, repeat, ...bytes] 307 308 // The three bytes after the row number. niimbluelib computes them two ways 309 // and niimprint reports that zeros are accepted on every device it supports, 310 // so this is evidently not strictly validated â but send something correct 311 // anyway rather than relying on that. 312 // 313 // 'zero' = [0,0,0], what d110 sends. 'split' = set-bit count in each third of 314 // the row buffer, which needs the byte width to divide by 3 â true for every 315 // head we support (96 dots = 12 bytes, 384 = 48, 120 = 15). 'total' = 316 // [0, count_lo, count_hi] over the whole row. 317 // 318 // Where a count IS sent it counts SET bits, which under the 1-is-black 319 // polarity is what the field is named for: black dots. 320 function pixelCounts(rowBytes, mode) { 321 var total = 0, i; 322 // 'zero' is what niimprint sends on every model it supports, with the 323 // comment that zeros always seem to be accepted. It is the safest choice 324 // where the field's meaning is uncertain, and it is what the d110 task uses. 325 if (mode === 'zero') return [0, 0, 0]; 326 var popcount = function (b) { 327 b = b - ((b >> 1) & 0x55); 328 b = (b & 0x33) + ((b >> 2) & 0x33); 329 return (b + (b >> 4)) & 0x0F; 330 }; 331 if (mode === 'total' || rowBytes.length % 3 !== 0) { 332 for (i = 0; i < rowBytes.length; i++) total += popcount(rowBytes[i]); 333 return [0, total & 0xFF, (total >> 8) & 0xFF]; 334 } 335 var third = rowBytes.length / 3, parts = [0, 0, 0]; 336 for (i = 0; i < rowBytes.length; i++) parts[Math.floor(i / third)] += popcount(rowBytes[i]); 337 return [parts[0] & 0xFF, parts[1] & 0xFF, parts[2] & 0xFF]; 338 } 339 340 // ââ Image â row packets ââ 341 // 342 // Rows run ALONG the label and columns run ACROSS it, which is exactly how 343 // the shared renderer already builds its canvas (width = across the tape), so 344 // no rotation is needed here. 345 // 346 // EVERY row goes out as its own 0x85 with repeat 1, and blank rows are sent 347 // as pixel data like any other. That is deliberately the dumbest possible 348 // encoding, and it is what niimprint does â the implementation known to work 349 // on this printer family. 350 // 351 // `opts.rle` turns on the clever version: identical consecutive rows collapse 352 // into one packet with a repeat count, and blank runs become a 0x84 carrying 353 // no pixel payload. On a sticker that is mostly empty tape it is a big saving 354 // (220 packets instead of 320 for a 96x320 label). 355 // 356 // It is OFF by default and has still never printed a label. It was on for six 357 // device tests that all came out blank â but those were blank because the 358 // ROLL WAS UPSIDE DOWN, with the thermal coating facing away from the head, 359 // so they exonerate nothing. Do not turn this back on without a print to 360 // prove it. 361 function encodeRows(raster, widthDots, heightDots, opts) { 362 opts = opts || {}; 363 var widthBytes = Math.ceil(widthDots / 8); 364 var packets = []; 365 var y = 0; 366 367 var rowAt = function (n) { return raster.subarray(n * widthBytes, (n + 1) * widthBytes); }; 368 var emitPixels = function (row, at, run) { 369 var head = u16(at).concat(pixelCounts(row, opts.countMode), [run]); 370 var data = new Uint8Array(head.length + row.length); 371 data.set(head, 0); 372 data.set(row, head.length); 373 packets.push(packet(ROW_PIXELS, data)); 374 }; 375 376 if (!opts.rle) { 377 for (; y < heightDots; y++) emitPixels(rowAt(y), y, 1); 378 return packets; 379 } 380 381 // Blank is whatever byte means "no dots", which flips with the polarity. 382 var blankByte = opts.invert ? 0xFF : 0x00; 383 var isBlank = function (row) { 384 for (var i = 0; i < row.length; i++) if (row[i] !== blankByte) return false;
385 return true; 386 }; 387 var same = function (a, b) { 388 for (var i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; 389 return true; 390 }; 391 392 while (y < heightDots) { 393 var row = rowAt(y); 394 // Repeat runs are capped at 255 â `repeat` is a single byte. 395 var run = 1; 396 while (y + run < heightDots && run < 255 && same(row, rowAt(y + run))) run++; 397 398 if (isBlank(row)) packets.push(packet(ROW_BLANK, u16(y).concat([run]))); 399 else emitPixels(row, y, run); 400 y += run; 401 } 402 return packets; 403 } 404 405 // Canvas rendered at the label's exact dot dimensions â row packets. 406 function encodeCanvas(canvas, tapeKey, opts) { 407 var geo = tapeGeometry(tapeKey); 408 if (canvas.width !== geo.widthDots || canvas.height !== geo.heightDots) { 409 throw new Error( 410 'Canvas is ' + canvas.width + 'x' + canvas.height + ' but label ' + 411 tapeKey + ' needs ' + geo.widthDots + 'x' + geo.heightDots + ' dots' 412 ); 413 } 414 var ctx = canvas.getContext('2d'); 415 var img = ctx.getImageData(0, 0, geo.widthDots, geo.heightDots); 416 var raster = packRaster(img.data, geo.widthDots, geo.heightDots, opts); 417 return { geo: geo, raster: raster, rows: encodeRows(raster, geo.widthDots, geo.heightDots, opts) }; 418 } 419 420 // ââ BLE transport ââ 421 // 422 // Same constraints and calling convention as nelko-p21.js: no bundler on the 423 // page, so we talk to window.Capacitor.Plugins.BluetoothLe directly rather 424 // than importing BleClient (whose IIFE would need a second copy of 425 // @capacitor/core and risks a parallel plugin registry). Options are objects, 426 // `value` is a lowercase hex string on native, UUIDs are full 128-bit lowercase. 427 var LAST_DEVICE_KEY = 'niimbot_device_id'; 428 var LAST_NAME_KEY = 'niimbot_device_name'; 429 // The print dialects. 'd110' first because it is what every 203dpi 430 // D110/D11 wants and is the default here; the others are for models the 431 // MODELS table maps to them. 432 var TASKS = ['d110', 'b1', 'v4']; 433 434 function ble() { 435 var C = typeof window !== 'undefined' && window.Capacitor; 436 if (!C || !C.Plugins) return null; 437 return C.Plugins.BluetoothLe || null; 438 } 439 440 function isAvailable() { 441 var C = typeof window !== 'undefined' && window.Capacitor; 442 if (!C) return false; 443 // isPluginAvailable, not truthiness of Plugins.X â the latter is a proxy 444 // and is truthy even when the native class was never registered. 445 if (typeof C.isPluginAvailable === 'function') return C.isPluginAvailable('BluetoothLe'); 446 return !!ble(); 447 } 448 449 function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); } 450 451 // initialize() runs at most ONCE PER PAGE, across every caller. 452 // 453 // On iOS the plugin builds a fresh DeviceManager â and so a fresh 454 // CBCentralManager â every time this is called. That matters far more than 455 // it looks: **a CBPeripheral discovered by one central manager cannot be 456 // connected by a different one.** The connect is accepted, the plugin even 457 // logs "Connecting to peripheral <CBPeripheral â¦>" because it found the 458 // object in its own dictionary, and then nothing happens until the timeout. 459 // 460 // Device test 2 (2026-08-11) showed exactly that: initialize from the sheet's 461 // shared scan, initialize again from this driver, then three connects that 462 // all timed out against a printer sitting at RSSI -56. 463 // 464 // The guard therefore lives on `window`, NOT in this module â the scan in 465 // app.html and BOTH drivers have to land on the same central manager, and a 466 // module-local flag only deduped this file's own calls (which is why the 467 // first attempt at this fix changed nothing). 468 function ensureInit() { 469 var w = typeof window !== 'undefined' ? window : {}; 470 if (!w.__labelBleInit) w.__labelBleInit = Promise.resolve(ble().initialize({})); 471 return w.__labelBleInit; 472 } 473 474 // Connect, with retries. 475 // 476 // "Connection timeout" against a printer sitting at RSSI -47 is not a range 477 // problem â a first attempt routinely fails on these boards while the second 478 // succeeds. The disconnect between attempts is the load-bearing part: a 479 // timed-out connect leaves CoreBluetooth still pending on that peripheral, 480 // and the next connect fails instantly against the stale attempt rather than 481 // starting a new one. 482 async function connectWithRetry(deviceId, opts) { 483 opts = opts || {}; 484 var tries = opts.tries || 3; 485 var timeout = opts.timeout || 12000; 486 var BLE = ble(); 487 var last = null; 488 for (var i = 0; i < tries; i++) { 489 if (i > 0) { 490 if (opts.onRetry) opts.onRetry(i + 1, tries); 491 try { await BLE.disconnect({ deviceId: deviceId }); } catch (e) {} 492 // Wait out the teardown, don't just wait. 493 // 494 // disconnect() resolves as soon as iOS accepts the request, not when 495 // the link is down. Device test 2 caught retries 2 and 3 firing 496 // against a peripheral still in `state = disconnecting`, which cannot 497 // accept a connection â so the retry was guaranteed to fail no matter 498 // how many times it ran. 499 await sleep(1800 + i * 700); 500 }
501 try { 502 await BLE.connect({ deviceId: deviceId, timeout: timeout }); 503 return true; 504 } catch (e) { last = e; } 505 } 506 throw last || new Error('Could not connect to the printer'); 507 } 508 509 function toHex(bytes) { 510 var s = ''; 511 for (var i = 0; i < bytes.length; i++) { 512 var h = bytes[i].toString(16); 513 s += h.length === 1 ? '0' + h : h; 514 } 515 return s; 516 } 517 518 function toBytes(value) { 519 try { 520 if (value == null) return null; 521 if (typeof value === 'string') { 522 var hex = value.replace(/[^0-9a-fA-F]/g, ''); 523 var out = []; 524 for (var i = 0; i + 1 < hex.length; i += 2) out.push(parseInt(hex.substr(i, 2), 16)); 525 return out; 526 } 527 // getUint8 FIRST â a typed array also has .buffer, and testing that first 528 // sends Uint8Array down the DataView branch and throws (the exact bug 529 // that made the P21's decodeNotify silently return ''). 530 if (typeof value.getUint8 === 'function') { 531 var b = []; 532 for (var j = 0; j < value.byteLength; j++) b.push(value.getUint8(j)); 533 return b; 534 } 535 if (value.length != null) return Array.prototype.slice.call(value); 536 } catch (e) {} 537 return null; 538 } 539 540 // Names look like "D11-A1B2" / "D110-XXXX" / "B21-XXXX"; some firmware 541 // prefixes NIIMBOT. Matching on the advertised service is the reliable path, 542 // the name is the fallback. 543 function matches(device, scanResult) { 544 var uuids = (scanResult && (scanResult.uuids || (scanResult.device && scanResult.device.uuids))) || device.uuids || []; 545 for (var i = 0; i < uuids.length; i++) { 546 if (String(uuids[i]).toLowerCase() === GATT.service) return true; 547 } 548 var name = (device.name || (scanResult && scanResult.localName) || '').toUpperCase(); 549 if (!name) return false; 550 if (name.indexOf('NIIMBOT') >= 0) return true; 551 return !!modelFor(name); 552 } 553 554 var Printer = { 555 deviceId: null, 556 deviceName: null, 557 model: DEFAULT_MODEL, 558 chunkSize: 0, 559 _parser: null, 560 _waiters: [], 561 _onLog: null, 562 563 connect: async function (opts) { 564 opts = opts || {}; 565 if (!isAvailable()) throw new Error('Bluetooth printing requires the Vendy app'); 566 var BLE = ble(); 567 await ensureInit(); 568 569 var id = opts.deviceId || null; 570 if (!id && opts.reuseLast !== false) { 571 try { id = localStorage.getItem(LAST_DEVICE_KEY); } catch (e) {} 572 } 573 574 if (id) { 575 // Short timeout on the remembered printer. An awake unit answers almost 576 // immediately, so a long wait only makes a sleeping printer look like a 577 // hung app before the scan even starts. 578 if (opts.onStage) opts.onStage('reusing'); 579 try { 580 await BLE.connect({ deviceId: id, timeout: opts.reuseTimeout || 3000 }); 581 this.deviceId = id; 582 try { this.deviceName = localStorage.getItem(LAST_NAME_KEY) || 'Niimbot'; } 583 catch (e) { this.deviceName = 'Niimbot'; } 584 this._applyModel(this.deviceName); 585 await this._negotiate(); 586 // Notifications BEFORE the handshake â every command below waits for a 587 // reply, so an unsubscribed link makes the handshake time out 588 // silently and the printer then accepts setup commands forever 589 // without ever printing. 590 await this.watch(null); 591 await this.handshake(); 592 return id; 593 } catch (e) { 594 // noPicker exists for the same reason it does on the P21: an 595 // automated retry must never take over the screen with system UI. 596 if (opts.noPicker) throw e; 597 id = null; 598 } 599 } 600 601 if (opts.onStage) opts.onStage('scanning'); 602 var device = opts.device || await this.findPrinter(opts.scanMs || 6000); 603 if (!device) { 604 var err = new Error('No Niimbot found. Check the printer is switched on and nearby.'); 605 err.code = 'NOT_FOUND'; 606 throw err; 607 } 608 609 if (opts.onStage) opts.onStage('connecting'); 610 await connectWithRetry(device.deviceId, { 611 onRetry: function (n, of) { if (opts.onStage) opts.onStage('retrying', n, of); }, 612 }); 613 this.deviceId = device.deviceId; 614 this.deviceName = device.name || 'Niimbot'; 615 this._applyModel(this.deviceName);
616 try { 617 localStorage.setItem(LAST_DEVICE_KEY, device.deviceId); 618 localStorage.setItem(LAST_NAME_KEY, this.deviceName); 619 } catch (e) {} 620 await this._negotiate(); 621 await this.watch(null); 622 await this.handshake(); 623 return device.deviceId; 624 }, 625 626 // Head width drives the raster, so an unrecognised name must not silently 627 // print at the wrong width â fall back to the D11 (96) and say so. 628 _applyModel: function (name) { 629 var m = modelFor(name); 630 this.model = m || DEFAULT_MODEL; 631 this.modelKnown = !!m; 632 activeHead = this.model.head; 633 // The button says "Niimbot" now, so the advertised name â which carries 634 // the serial â only survives here. 635 this._notes = []; 636 this.note(name + (m ? '' : ' (unknown model, assuming ' + DEFAULT_MODEL.id + ')')); 637 return this.model; 638 }, 639 640 // Silent scan, no system picker (see nelko-p21.js findPrinter for why 641 // requestDevice is the wrong shape here). 642 findPrinter: async function (timeoutMs) { 643 var BLE = ble(); 644 if (!BLE) return null; 645 var ms = timeoutMs || 6000; 646 var handle = null, timer = null, settled = false; 647 try { 648 return await new Promise(function (resolve) { 649 var done = function (dev) { 650 if (settled) return; 651 settled = true; 652 clearTimeout(timer); 653 resolve(dev || null); 654 }; 655 Promise.resolve(BLE.addListener('onScanResult', function (res) { 656 var d = res && (res.device || res); 657 if (!d || !d.deviceId) return; 658 if (matches(d, res)) done(d); 659 })).then(function (h) { 660 handle = h; 661 if (settled) return; // a match can land before the handle resolves 662 return BLE.requestLEScan({ allowDuplicates: false }); 663 }).catch(function () { done(null); }); 664 timer = setTimeout(function () { done(null); }, ms); 665 }); 666 } finally { 667 try { await BLE.stopLEScan(); } catch (e) {} 668 try { if (handle && handle.remove) await handle.remove(); } catch (e) {} 669 } 670 }, 671 672 _negotiate: async function () { 673 var fallback = 20; 674 try { 675 var r = await ble().getMtu({ deviceId: this.deviceId }); 676 var mtu = r && (r.value || r.mtu); 677 this.chunkSize = mtu && mtu > 23 ? Math.min(mtu - 3, 512) : fallback; 678 } catch (e) { 679 this.chunkSize = fallback; 680 } 681 return this.chunkSize; 682 }, 683 684 disconnect: async function () { 685 if (!this.deviceId || !isAvailable()) return; 686 // Drop the notification listener explicitly. Its key includes the 687 // deviceId, so reconnecting to the SAME printer without removing it 688 // leaves two listeners on one key and every frame arrives twice. 689 try { if (this._notifyHandle && this._notifyHandle.remove) await this._notifyHandle.remove(); } catch (e) {} 690 try { await ble().disconnect({ deviceId: this.deviceId }); } catch (e) {} 691 this.deviceId = null; 692 this.deviceName = null; 693 this._parser = null; 694 this._waiters = []; 695 this._watching = false; 696 this._notifyHandle = null; 697 }, 698 699 // Subscribe to the notify side of the shared characteristic. Unlike the 700 // P21 this is NOT optional â every command is acked and the page counter is 701 // the only way to know a label came out, so a failure here has to surface. 702 // 703 // IDEMPOTENT, and it has to be: connect() subscribes before the handshake 704 // (which needs replies to work at all), and the sheet then calls watch() 705 // again to attach its log panel. Re-subscribing would double every frame. 706 watch: async function (onData) { 707 if (!this.deviceId) return; 708 var self = this; 709 if (this._watching) { 710 this._onLog = onData || null; 711 // Replay anything noted before a logger existed â the connect-time 712 // protocol version and print task are noted from inside connect(). 713 if (onData && this._notes) { 714 this._notes.forEach(function (t) { try { onData({ note: t }); } catch (e) {} }); 715 } 716 return; 717 } 718 var BLE = ble(), id = this.deviceId; 719 this._parser = new Parser(); 720 this._onLog = onData || null; 721 var key = 'notification|' + id + '|' + GATT.service + '|' + GATT.io; 722 this._notifyHandle = await BLE.addListener(key, function (event) { 723 var bytes = toBytes(event && event.value); 724 if (!bytes) return; 725 var frames = self._parser.push(bytes); 726 for (var i = 0; i < frames.length; i++) self._dispatch(frames[i]); 727 }); 728 await BLE.startNotifications({ deviceId: id, service: GATT.service, characteristic: GATT.io }); 729 this._watching = true; 730 }, 731 732 _dispatch: function (frame) { 733 if (this._onLog) { 734 try { this._onLog(frame); } catch (e) {} 735 } 736 for (var i = 0; i < this._waiters.length; i++) { 737 var w = this._waiters[i]; 738 if (w.res === frame.type) { 739 this._waiters.splice(i, 1); 740 w.resolve(frame); 741 return; 742 } 743 } 744 }, 745 746 // Render a frame for the diagnostics panel. Niimbot replies are binary, so 747 // unlike the P21's ASCII this is hex with the type named where we know it. 748 // Push our OWN line into the sheet's log panel. A blank label with a happy 749 // printer is only diagnosable if the panel says what we SENT â row count, 750 // declared page size, which print task â beside what came back. 751 // Buffered, because the most valuable notes happen during connect() â the 752 // protocol version and resolved print task â and the sheet only attaches 753 // its logger once connect() has RESOLVED. Writing straight to _onLog threw 754 // exactly those lines away. 755 note: function (text) { 756 if (!this._notes) this._notes = []; 757 this._notes.push(text); 758 if (this._notes.length > 20) this._notes.shift(); 759 if (this._onLog) { try { this._onLog({ note: text }); } catch (e) {} } 760 }, 761 762 decodeNotify: function (frame) { 763 if (!frame) return ''; 764 if (frame.note) return '· ' + frame.note; 765 if (frame.type == null) { 766 // Raw value handed straight through (e.g. before watch() parsed it). 767 var raw = toBytes(frame); 768 return raw ? raw.map(function (b) { return ('0' + b.toString(16)).slice(-2); }).join(' ') : ''; 769 } 770 var names = { 771 0xB5: 'status', 0xD9: 'heartbeat', 0x31: 'density ok', 0x33: 'label-type ok', 772 0x02: 'print-start ok', 0x04: 'page-start ok', 0x14: 'page-size ok', 773 0xE4: 'page-end ok', 0xF4: 'print-end ok', 0xB3: 'print-status', 774 0xDB: 'error', 775 }; 776 var label = names[frame.type] || ('0x' + ('0' + frame.type.toString(16)).slice(-2)); 777 // Never render a rejection as "ok". The flag is the first data byte, and 778 // reading the command name instead of the flag is what let a rejected 779 // print job look identical to a successful one. 780 if (/ ok$/.test(label) && frame.data.length && frame.data[0] === 0x00) { 781 label = label.replace(/ ok$/, ' REJECTED'); 782 } 783 var hex = frame.data.map(function (b) { return ('0' + b.toString(16)).slice(-2); }).join(' '); 784 if (frame.type === 0xB3 && frame.data.length >= 4) { 785 // page counter (2 BE) + two progress bytes 786 var page = (frame.data[0] << 8) | frame.data[1]; 787 return 'print-status page=' + page + ' [' + hex + ']'; 788 } 789 return label + (hex ? ' [' + hex + ']' : ''); 790 }, 791 792 // Raw write, chunked to the negotiated MTU. Write-without-response, so 793 // there is no transport backpressure â the pacing below is the only lever, 794 // same as the P21. 795 write: async function (bytes, opts) { 796 opts = opts || {}; 797 if (!this.deviceId) throw new Error('Printer not connected'); 798 var BLE = ble(); 799 var size = opts.chunkSize || this.chunkSize || 20; 800 var pace = opts.delayMs == null ? 8 : opts.delayMs; 801 for (var i = 0; i < bytes.length; i += size) { 802 await BLE.writeWithoutResponse({ 803 deviceId: this.deviceId, 804 service: GATT.service, 805 characteristic: GATT.io, 806 value: toHex(bytes.subarray(i, Math.min(i + size, bytes.length))), 807 }); 808 if (pace) await sleep(pace); 809 } 810 }, 811 812 // Send a command and wait for its specific reply code. 813 transceive: async function (cmd, data, timeoutMs) { 814 var self = this; 815 var pkt = packet(cmd.req, data || cmd.data || [0x01]); 816 if (!cmd.res) { await this.write(pkt); return null; } 817 818 var waiter = null; 819 var reply = new Promise(function (resolve, reject) { 820 waiter = { res: cmd.res, resolve: resolve }; 821 self._waiters.push(waiter); 822 setTimeout(function () { 823 var i = self._waiters.indexOf(waiter); 824 if (i < 0) return; // already answered 825 self._waiters.splice(i, 1); 826 reject(new Error('Printer did not answer 0x' + cmd.req.toString(16))); 827 }, timeoutMs || 3000); 828 }); 829 await this.write(pkt); 830 var frame = await reply; 831 832 // A reply is not the same as a success. On `ack` commands the first data 833 // byte is 01 accepted / 00 rejected, and ignoring it hid a whole failed 834 // print job behind a log full of lines reading "ok". 835 if (frame && cmd.ack && frame.data.length && frame.data[0] === 0x00) { 836 var err = new Error('Printer REJECTED 0x' + cmd.req.toString(16) + 837 ' (replied 00, not 01)'); 838 err.code = 'REJECTED'; 839 this.note('REJECTED 0x' + cmd.req.toString(16) + ' â printer answered 00'); 840 throw err; 841 } 842 return frame; 843 }, 844 845 // ââ Post-connect handshake ââ 846 // 847 // NOT optional and NOT cosmetic. Per the niimbluelib protocol notes, without 848 // this exact sequence the printer "accepts every setup command but never 849 // starts printing" â i.e. the failure mode is a silent no-op where every 850 // ack looks fine, which is the worst kind to debug. Ask for it up front. 851 // 852 // The info queries are best-effort: their answers only enrich the log, and 853 // some are unimplemented on some firmware, so a timeout on one must not 854 // abort the connection. 855 handshake: async function () { 856 // Clear any dialect an older build persisted. The chip that wrote this is 857 // gone, but a value left in localStorage would otherwise still be read by 858 // nothing and confuse the next person who greps for the key.
859 try { localStorage.removeItem('niimbot_task_override'); } catch (e) {} 860 try { await this.transceive(CMD.printerStatusData); } catch (e) {} 861 // 0x10 (protocol version) is NOT in this list, on purpose. 862 // 863 // Device test 4 asked for it and the printer answered `55 55 00 01 01` 864 // â response type 0x00, which is "unsupported command", not a version. 865 // The documented PrinterInfo responses run 0x41-0x4f and 0x50 is not 866 // among them, so the query I was waiting on could never have arrived. 867 // It timed out, the task fell back to b1, and the label came out blank. 868 // 869 // The print task is chosen by MODEL now (see MODELS above), which is how 870 // niimbluelib's own mapping does it. Sub 0x08 is the real identifier: 871 // it returned 0x0910 = 2320, the D110_M's model id. 872 var subs = [0x08, 0x0B, 0x0D, 0x0A, 0x07, 0x03, 0x0C, 0x09]; 873 this.info = {}; 874 for (var i = 0; i < subs.length; i++) { 875 try { 876 var r = await this.transceive( 877 { req: CMD.printerInfo.req, res: CMD.printerInfo.req + subs[i] }, [subs[i]], 900); 878 if (r) this.info[subs[i]] = r.data; 879 } catch (e) { /* unimplemented on this firmware â keep going */ } 880 } 881 try { await this.transceive(CMD.heartbeat); } catch (e) {} 882 883 // The MODEL decides the dialect, full stop. There used to be a UI 884 // override, because each wrong guess costs a native rebuild to test and 885 // the dialects are indistinguishable from their acks. It is gone now that 886 // hardware has settled which one this printer speaks â and it earned its 887 // removal twice over, having silently won on two device tests that were 888 // meant to be exercising something else. 889 this.task = this.model.task || 'd110'; 890 var id = this.info[0x08]; 891 this.note((id && id.length >= 2 ? 'model id ' + ((id[0] << 8) | id[1]) + ', ' : '') + 892 this.model.id + ', head ' + this.model.head + ' â ' + this.task + ' task'); 893 return true; 894 }, 895 896 density: function () { 897 var d = this.model.density; 898 return d.def; 899 }, 900 901 // ââ Print task ââ 902 // 903 // Which dialect this printer speaks. Set from the MODEL table during the 904 // handshake (the protocol version is unqueryable â see handshake). 'd110' 905 // is the standing default because it is the 203dpi D110/D11 sequence and 906 // it is the only one of the three that sets a copy count this firmware 907 // actually reads. 908 task: 'd110', 909 910 printInit: async function (totalPages, opts) { 911 opts = opts || {}; 912 // setLabelType FIRST, then density â that is the order in the official 913 // app's own traffic (btsnoop capture, 2026-08-13). We had it reversed. 914 await this.transceive(CMD.setLabelType, [opts.labelType == null ? 1 : opts.labelType]); 915 await this.transceive(CMD.setDensity, [opts.density == null ? this.density() : opts.density]); 916 // printStart's length is the dialect's most visible difference, and all 917 // three answer 0x02 â so the reply can never tell them apart. 918 // d110: 1 byte. Page count is not declared here at all; copies are set 919 // per page by printQuantity. 920 // b1: 7 bytes, leading with the total page count. 921 // v4: 9 bytes, with a speed field. 922 var payload = 923 this.task === 'd110' ? [0x01] : 924 this.task === 'v4' ? u16(totalPages).concat([0, 0, 0, 0, 0, opts.speed == null ? 1 : opts.speed, 0]) : 925 u16(totalPages).concat([0, 0, 0, 0, opts.pageColor || 0]); 926 await this.transceive(CMD.printStart, payload); 927 }, 928 929 // A page, in whichever dialect. The two differ by more than payload 930 // lengths â v4 has NO PageStart and NO PageEnd at all, so this cannot be 931 // one sequence with a few branches inside it. 932 printPage: async function (canvas, tapeKey, opts) { 933 opts = opts || {}; 934 var task = this.task; 935 var v4 = task === 'v4', d110 = task === 'd110'; 936 var copies = opts.copies || 1; 937 938 // Per-row dot counts. Each dialect its own reference's way: d110 sends 939 // ZEROS (niimprint, the known-working 203dpi implementation), b1 splits 940 // the count across thirds of the printhead, v4 sends one 16-bit total. 941 // 942 // d110 was documented as 'zero' from the start and was passing 'split' 943 // anyway â never wired up. It is not a cosmetic mismatch: it makes what 944 // the printer draws depend on how much BLACK is in the row, which is 945 // exactly the reading of this field that would make a mostly-white label 946 // come out blank while a mostly-black one floods. Both of those were 947 // observed, one per polarity, and that pair of results is what a 948 // count-gated firmware looks like â so the field goes back to the value 949 // the reference sends, and stops being a variable.
950 var enc = encodeCanvas(canvas, tapeKey, Object.assign({}, opts, { 951 countMode: opts.countMode || (v4 ? 'total' : d110 ? 'zero' : 'split'), 952 invert: !!opts.invert, 953 })); 954 955 // d110 clears the page buffer first. Best-effort â not every firmware 956 // implements it, and it must not take the print down with it. 957 // 958 // Not sent on v4 at all â the official app does not, and v4 is what this 959 // printer speaks. Kept for the d110 dialect, which no supported model 960 // currently selects. 961 if (d110) { try { await this.transceive(CMD.printClear, [0x01], 1200); } catch (e) {} } 962 963 // v4 has no page-open command at all; sending one is not harmless. 964 if (!v4) await this.transceive(CMD.pageStart); 965 966 // setPageSize: 4 bytes on d110 (rows, cols â copies are their own 967 // command), 6 on b1 (rows, cols, copies), 13 on v4. 968 var size = u16(enc.geo.heightDots).concat(u16(enc.geo.widthDots)); 969 if (!d110) size = size.concat(u16(copies)); 970 if (v4) size = size.concat([0, 0, 0, 0, 0, 0, 0]); 971 await this.transceive(CMD.setPageSize, size); 972 973 // THE command that was missing for five device tests. On d110 the copy 974 // count is set here, not inside setPageSize, and without it the count 975 // stays zero â the page runs to 100%, the page counter increments, and 976 // the head never heats. A perfect blank label with a happy printer. 977 if (d110) await this.transceive(CMD.printQuantity, u16(copies)); 978 979 // v4 wants a status request here that is NOT waited on â it primes the 980 // page rather than asking anything, so awaiting a reply just stalls. 981 if (v4) { try { await this.write(packet(CMD.printStatus.req, [0x01])); } catch (e) {} } 982 983 // Row packets are unacked and this printer drops them silently if they 984 // arrive too fast. ~10ms apart, on EVERY task. 985 // 986 // v4 used to burst them unpaced, on the reading that the v4 task belongs 987 // to 300dpi hardware that can take it. This is a 203dpi D110_M, and its 988 // own logs settle it: paced at 10ms it reports rows received (0x
988D3 for 989 // row 199, row 319); blasted unpaced it reports NONE, and then does not 990 // answer PageEnd at all â a printer that has lost its place mid-page, not 991 // one that finished. The dialect says nothing about how fast the wire can 992 // go; the hardware does. 993 var rowPace = opts.rowDelayMs == null ? 10 : opts.rowDelayMs; 994 for (var i = 0; i < enc.rows.length; i++) { 995 await this.write(enc.rows[i], { delayMs: 0 }); 996 if (opts.onRow) opts.onRow(i + 1, enc.rows.length); 997 if (rowPace) await sleep(rowPace); 998 } 999 1000 // How much INK is in what we just sent, as a percentage of the page. 1001 // 1002 // Every blank label so far has been diagnosed by reasoning backwards from 1003 // the printer's behaviour, and the printer cannot tell us the one thing 1004 // that matters: whether the rows it received had anything in them. A 1005 // solid-black test prints and a real label does not, and without this 1006 // number there is no way to tell a renderer producing an empty canvas 1007 // from a printer refusing to draw a sparse one. It costs one pass over a 1008 // 3.8KB buffer, once per page. 1009 var ink = 0; 1010 for (var b = 0; b < enc.raster.length; b++) { 1011 var v = enc.raster[b]; 1012 if (opts.invert) v = ~v & 0xFF; // count black, whichever way it was packed 1013 v = v - ((v >> 1) & 0x55); 1014 v = (v & 0x33) + ((v >> 2) & 0x33); 1015 ink += (v + (v >> 4)) & 0x0F; 1016 } 1017 var pct = Math.round((ink / (enc.geo.widthDots * enc.geo.heightDots)) * 100); 1018 1019 this.note(enc.rows.length + ' pkts / ' + enc.geo.heightDots + ' rows, ' + 1020 enc.geo.widthDots + 'Ã' + enc.geo.heightDots + ' dots, ' + copies + 'Ã, ' + task + 1021 ', ink ' + ink + ' dots (' + pct + '%)' + 1022 (opts.rle ? ', rle' : '')); 1023 1024 // EVERY dialect closes the page, v4 included. This used to be `if (!v4)` 1025 // on the reading that v4 has no page framing at all â it has no 1026 // pageSTART, but it very much sends pageEND, and the official app's 1027 // capture shows it. Without it the page never completes, which is 1028 // precisely the "v4 stalls, page counter never moves" result that got v4 1029 // wrongly eliminated and sent this driver down the d110 path. 1030 await this.transceive(CMD.pageEnd, null, 5000); 1031 return enc.rows.length; 1032 }, 1033 1034 // Poll the page counter until the printer reports `target` pages done. 1035 // 1036 // Falls back to resolving on timeout rather than throwing: a print that 1037 // physically came out but whose status we lost should not report failure 1038 // and send the user to reprint a label they already have. 1039 waitForPage: async function (target, timeoutMs) { 1040 var deadline = Date.now() + (timeoutMs || 20000); 1041 while (Date.now() < deadline) { 1042 try { 1043 var r = await this.transceive(CMD.printStatus, null, 2000); 1044 if (r && r.data.length >= 2) { 1045 var page = (r.data[0] << 8) | r.data[1]; 1046 if (page >= target) return page; 1047 } 1048 } catch (e) { /* keep polling until the deadline */ } 1049 await sleep(250); 1050 } 1051 return -1; 1052 }, 1053 1054 // v4 closes with a heartbeat that is not waited on. Harmless on b1, but 1055 // only sent where it's documented so the two sequences stay honest. 1056 printEnd: async function () { 1057 var r = await this.transceive(CMD.printEnd, null, 5000); 1058 if (this.task === 'v4') { 1059 try { await this.write(packet(CMD.heartbeat.req, CMD.heartbeat.data)); } catch (e) {} 1060 } 1061 return r; 1062 }, 1063 1064 // One page, start to finish. Mirrors NelkoP21.Printer.printCanvas so a 1065 // single-label caller looks the same on both drivers. 1066 printCanvas: async function (canvas, tapeKey, opts) { 1067 await this.printInit(1, opts); 1068 await this.printPage(canvas, tapeKey, opts); 1069 await this.waitForPage(1, (opts && opts.pageTimeoutMs) || 20000); 1070 await this.printEnd(); 1071 return 1; 1072 }, 1073 1074 // Same signature as NelkoP21.Printer.printBatch â `render(i)` returns the 1075 // canvas for label i â but this waits on the printer's own page counter 1076 // instead of a fixed gap. 1077 printBatch: async function (count, render, tapeKey, opts) { 1078 opts = opts || {}; 1079 await this.printInit(count, opts); 1080
1081 try { 1082 for (var i = 0; i < count; i++) { 1083 if (opts.onLabel) opts.onLabel(i, count); 1084 await this.printPage(await render(i), tapeKey, opts); 1085 // Page counter is cumulative across the task, so label i is done when 1086 // it reads i+1. 1087 await this.waitForPage(i + 1, opts.pageTimeoutMs || 20000); 1088 } 1089 } finally { 1090 // ALWAYS close the task, even after a failure. A printer left inside an 1091 // open print task refuses the next printStart, so a single bad run 1092 // would otherwise brick every subsequent print until a power cycle. 1093 try { await this.printEnd(); } catch (e) {} 1094 } 1095 return count; 1096 }, 1097 1098 // No SELFTEST equivalent â the closest safe smoke test is a status read, 1099 // which proves framing, checksums and the notify path in one round trip. 1100 selfTest: async function () { 1101 var r = await this.transceive(CMD.printerStatusData, null, 3000); 1102 return this.decodeNotify(r); 1103 }, 1104 }; 1105 1106 return { 1107 GATT: GATT, 1108 DPI: DPI, 1109 BLACK_BIT: BLACK_BIT, 1110 MODELS: MODELS, 1111 modelFor: modelFor, 1112 TAPES: TAPES, 1113 mmToDots: mmToDots, 1114 tapeGeometry: tapeGeometry, 1115 packRaster: packRaster, 1116 packet: packet, 1117 Parser: Parser, 1118 pixelCounts: pixelCounts, 1119 encodeRows: encodeRows, 1120 encodeCanvas: encodeCanvas, 1121 CMD: CMD, 1122 matches: matches, 1123 TASKS: TASKS, 1124 Printer: Printer, 1125 isAvailable: isAvailable, 1126 // Exported so app.html's shared scan initialises through the SAME guard. 1127 // If the scan calls the plugin's initialize directly it creates a second 1128 // central manager and nothing discovered by it can be connected. 1129 ensureInit: ensureInit, 1130 LAST_DEVICE_KEY: LAST_DEVICE_KEY, 1131 LAST_NAME_KEY: LAST_NAME_KEY, 1132 // Test seam: the raster width depends on the connected model, and the host 1133 // harness has no printer to connect to. 1134 _setHead: function (n) { activeHead = n; }, 1135 }; 1136})(); 1137 1138if (typeof module !== 'undefined' && module.exports) module.exports = Niimbot;
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.