PageSourceSearch

https://onvendy.com/niimbot.js

js onvendy.com collected 2026-10-02 01:38:32 UTC 51,384 bytes, 1,138 lines download raw bytes

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.