Printers and serial.

The player finds printers and serial ports and moves bytes. What the bytes say is yours: a receipt printer is sent ESC/POS, a label printer ZPL or TSPL, exactly as you built them.

Working — describes ArdaPlayer v1.2.3

Finding a printer

app/app.js
Arda.printers.list().then(function (list) {
  // [{id, make, model, proto, conn, addr, dpi, widthMM, dots, usable, error}]
  var receipt = list.filter(function (p) { return p.usable && p.proto === 'escpos'; })[0];
  if (!receipt) return say('No receipt printer: ' + list.map(function (p) { return p.error; }).join('; '));
  printReceipt(receipt);
});
  • proto: escpos, zpl, star, tspl, escp (Epson ESC/P: an impact or dot-matrix printer such as an LX-350 — text and ESC/P codes, no cutter, no receipt raster; from v1.2.2) or raw. raw means the player could not tell: never send it ESC/POS on a guess. An operator can set the language on the portal's Peripherals tab.
  • conn is how it is reached: usb, net (raw TCP 9100; addr is its host:port) or Bluetooth. All print the same way. A network printer is one the device was told about — on the portal's Peripherals tab, or by the app: below.
  • Which printer, per device. A select setting with "options": {"device": "printers"} lists the printers each device reports, in the portal and to the app; its value is the printer's id. From v1.2.1: see Settings.
  • Check usable. A printer that can be seen but not written to is worse than none. error says why.
  • dots is the printable width for a raster image — use it, not widthMM × dpi (80 mm at 203 dpi computes to 639; the real width is 576). On a receipt printer it is derived from the paper size and can be wrong; an operator can correct it on the Peripherals tab. A wrong width does not fail, it wraps.

Telling the player what a printer is from ArdaPlayer v1.5.1

Arda.printers.set(id, {name, columns, dots, proto}) is the operator’s override of a printer from the app — what it is called, its printable width in characters (count them off a test print; dots for somebody who knows the head) and its language (escpos, star, zpl, tspl, escp, raw) — written to the same record the device’s Peripherals tab writes, so the portal and the app never disagree. Send only the fields you mean: a field left out keeps what is held, '' or 0 clears it back to what the player detected, a value sets it — so set(id, {proto: 'star'}) changes only the language and set(id, {columns: 0}) puts the detected width back. It resolves with the printer as list() shows it afterwards.

From the same version every list() entry carries the values in force (name, proto, dots, columns — the override folded in, as the Peripherals tab shows) and, beside them, detected: {columns, dots, proto}, what the player worked out on its own, and override: {name, columns, dots, proto} while one is held — enough for an edit window to say “Auto (48)” and to put the detected value back. Not darkness, not the drawer pin: those are a job’s own bytes. And print() never translates: proto is what the player believes the printer speaks, for you to read — the bytes go through untouched.

Printing

app/receipt.js
// ESC/POS: text is a single-byte code page, not UTF-8.
var CP437 = { '£': 0x9c, 'é': 0x82, '°': 0xf8 };
function text(s) {
  // strip control bytes from anything that came from data: ESC (0x1b) and GS (0x1d)
  // inside a product name would be printer commands
  s = String(s).replace(/[\u0000-\u001f\u007f]/g, ' ');
  var out = [];
  for (var i = 0; i < s.length; i++) {
    var c = s.charCodeAt(i);
    out.push(c < 0x80 ? c : (CP437[s[i]] || 0x3f));   // '?' for anything unmapped
  }
  return out;
}

function receiptBytes(lines) {
  var b = [0x1b, 0x40, 0x1b, 0x74, 0x00];            // ESC @ reset, ESC t 0 = code page 437
  lines.forEach(function (l) { b = b.concat(text(l), [0x0a]); });
  b = b.concat([0x1b, 0x64, 0x04, 0x1d, 0x56, 0x42, 0x00]);   // feed 4 lines, then cut
  return new Uint8Array(b);
}

function printReceipt(p) {
  return Arda.printers.print(p.id, receiptBytes(['Tea          £2.50', 'Total        £2.50']))
    .then(function () { say('Printed'); }, function (e) { say('Printer: ' + e.message); });
}
  • print(id, bytes) takes a Uint8Array or ArrayBuffer and resolves when the bytes are written.
  • A £ sent as UTF-8 prints as two wrong characters and shifts every column of its row.
  • Feed before cutting, or the cutter slices the last line.

The ESC/POS byte values are the common Epson ones; printers differ. Not verified against a device here.

Copies and a status per copy

Since v1.1.11 (feature printStatus), print also takes options and a status callback:

app/labels.js
var stop = new AbortController();

Arda.printers.print(printer.id, labelBytes, { copies: 24, signal: stop.signal }, function (s) {
  // s = {state, done, copies, confirmed, code, error}
  if (s.state === 'printing') say(s.done + ' of ' + s.copies);
  if (s.state === 'queued')   say('Waiting for the printer');
  if (s.state === 'stalled')  say('Printer stopped: ' + (s.code || s.error));   // not the end
}).then(function (last) {
  say('Done: ' + last.copies + ' labels');
}, function (err) {
  say(err.status && err.status.state === 'cancelled' ? 'Cancelled' : 'Failed: ' + err.message);
});

cancelButton.onclick = function () { stop.abort(); };   // ends the job between copies
stateMeaning
printingcopy done of copies has gone
queuedanother job has this printer; this one waits
stalledthe printer stopped taking data or reports a fault (code: paper_out, cover_open, paper_jam, ribbon_out, paused, or stalled). Not final: the job carries on when the printer does.
donefinal: the promise resolves
error, cancelledfinal: the promise rejects; err.status is the last status (code unreachable or write_failed on error)
  • The bytes are sent copies times (1–999) over one connection. Either argument may be left out: print(id, bytes, onStatus) is one copy with a status.
  • confirmed: false means the printer took the copy, not that it came out. Say “sent to printer” unless it is true, and show something from the call until the promise settles: a Bluetooth print takes a second or three to dial before the first byte goes, and a blank screen meanwhile looks stuck. Most printers report nothing back; some take a whole job into memory with the cover open and print it when it closes. If the screen must follow the paper, time it yourself (label length ÷ print speed). confirmed: true appears only when the printer itself said so.
  • Cancelling — the signal, or the page going away — never cuts a copy in half (half a command in a printer garbles the next job). The copy in flight finishes, then the job ends cancelled.
  • A network printer that takes nothing for 30 s ends the job error; USB and Bluetooth jobs wait for the printer.
  • On an older player, test features for printStatus and fall back to print(id, bytes) in a loop.

Printers on the network from ArdaPlayer v1.3.0

A Wi-Fi or Ethernet receipt printer is not plugged into the device, so the device has to be told about it once. The portal's Peripherals tab does that (Find printers on the network, or an address typed in). An app can do it too, for a setup screen of its own:

app/setup.js
Arda.printers.discover().then(function (found) {
  // [{host, port, name, make, model, mac, known}] — known: already added on this device
  showChoices(found.map(function (f) {
    return { label: (f.name || 'Network printer') + '  ' + f.host + ':' + f.port, found: f };
  }));
});

function chosen(f) {
  return Arda.printers.add({ host: f.host, port: f.port, name: f.name })
    .then(function (p) {
      // p is the printer as list() shows it: {id: 'net:192.168.0.69:9100', conn: 'net', addr, proto, usable, error, ...}
      if (!p.usable) return say('Added, but not answering: ' + p.error);
      return Arda.printers.print(p.id, receiptBytes(['ArdaForm test']));   // printing is unchanged
    });
}

Arda.printers.remove('net:192.168.0.69:9100').then(function (r) { // r = {ok} });
  • discover() → [{host, port, name, make, model, mac, known}]. name, make, model and mac are what the printer said about itself and may be empty. From v1.5.1 far more printers say it: the device asks mDNS two ways (office printers such as a Canon or an Epson answer only a question sent from port 5353), asks a printer that advertises nothing for its model over SNMP, and takes its web page’s title last; known means it is already on this device. Finding a printer does not add it.
  • add({host, port, name, proto}) → the printer as list() shows it, with id net:host:port, conn: 'net' and addr. port is 9100 unless you give one; name and proto are optional. Adding an address a second time returns the printer already there. usable: false with an error means it was kept but is not answering.
  • remove(id) → {ok}. The name, width and protocol set for it on the Peripherals tab go with it.
  • Printing is unchanged: print(id, bytes, …) with the net: id, copies and status included. A network printer that takes nothing for 30 s ends the job error.

The rules. The device scans only when asked, never by itself, and only its own network: every address on it is asked once whether it takes print jobs on port 9100, which takes three to five seconds, and it does this at most once every 20 seconds — a second call inside that returns the last result. Anything answering on 9100 appears, so an office printer may appear beside the receipt printer: show the person what was found and let them choose rather than adding everything. The proto of a found printer is a labelled guess from what it said about itself; the Peripherals tab can correct it, and so can add's proto. On an older player none of the three calls exists: test features for netPrinters.

Bluetooth printers from ArdaPlayer v1.4.1

A receipt printer with a Bluetooth module — a Star TSP650 with its Bluetooth interface, a battery printer — is printed to over its serial port (RFCOMM), once the device is paired with it. Pairing is a one-time trust decision, and the player makes it the way a person would: one named printer, at your request, with its PIN if it has one. On the device’s Peripherals tab: Find Bluetooth printers listens for eight seconds and lists everything the radio heard, printers and serial-port devices first; Pair & add on the one you mean. Put the printer in pairing mode first — on the Star that is the PAIR button on the back held for about five seconds until its light flashes green (a short press does nothing, and a printer not in pairing mode refuses every attempt). It is then kept on the device as bt:address and appears in the Printers list, usable while the pairing holds.

  • discoverBluetooth() → [{address, name, class, printer, serial, paired, known, rssi, make, proto}]. make and proto (from v1.5.1) are the maker and the language the player read from the printer’s own module where it publishes one — a Star’s says 'Star Micronics', 'star' — on a Linux player; Android shows none, so there the name decides and set(id, {proto}) corrects it. addBluetooth() records that language unless you pass proto. printer says the device announces itself as one, serial that it offers the serial port a printer is printed over, paired that this device already holds a pairing, known that it is already added. Only when you ask, never on a timer.
  • addBluetooth({address, pin, name, proto, channel}) → the printer as list() shows it, id bt:address, conn: 'bt'. The call pairs first (allow up to a minute and a half) — somebody has pressed the printer’s pairing button — then trusts and remembers it. pin only when the printer wants one (a Star’s is 1234 when it does); proto is guessed from the name when you do not say (a name with TSP or Star means Star Line Mode) — a printer you have renamed needs proto: 'star' said, or the pencil on the Peripherals tab; channel is the serial port’s, 1 unless you know better.
  • Send a document as one print() call. Every call is its own dial of the printer’s serial port — a second or three on a Star after an idle — while a job’s bytes go over one open connection whatever their size. A receipt split into eight writes (a USB habit) takes most of ten seconds over Bluetooth; in one write, about one. The same holds for a network printer, where every call is a connect.
  • remove(id) works for a bt: id too. The pairing itself stays on the device (the Bluetooth pane can forget it), so a printer removed by mistake is added again without the walk to its button.
  • Printing is unchanged: print(id, bytes, …) with the bt: id. A connect the module refuses in its first moment is tried again twice, a moment apart; a printer out of range ends the job with an error. features says btPrinters where this works — Linux players (a Pi, an x86 till) from v1.4.1, and from v1.5.1 an Android tablet too, once Bluetooth is switched on in its settings (a tablet set up as device owner gives the player the Bluetooth permissions itself; an ordinary install asks the first time). Not the developer binaries.

A Star prints Star Line Mode

A Star Micronics receipt printer (proto: 'star') speaks its own language, not ESC/POS: ESC d 3 cuts (GS V does nothing, and ESC d n is a cut there, not a feed — feed with ESC a n), ESC i n1 n2 sizes, ESC GS a n aligns, ESC GS t 1 picks code page 437. Proved on a TSP650’s paper, 2026-10-29:

  • The logo is ESC X n1 n2 d… — the same 24-dot column stripes you build for ESC/POS ESC * 33 (three bytes a column, top byte first, the top dot the high bit, one dot per dot, n the number of columns, up to 576), sent under ESC 0 (a 3 mm line, 24 dots, so the stripes join) with ESC z 1 (4 mm) after. Not ESC k: on a Star that takes rows, and a count the printer reads as wider than the paper swallows the rest of the job as picture data and leaves the printer waiting for more.
  • A barcode is ESC b 6 2 1 h … RS: Code128, the digits printed under it, 2-dot modules, h the height in dots, the data plain ASCII between the parameters and RS (0x1E) — the printer picks the code sets itself.

Serial ports: Arda.serial

app/scale.js
// A scale on a USB-serial adapter. The protocol here is an example; yours is in the device's manual.
var port;
Arda.serial.list().then(function (ports) {
  // [{id, path, kind, vendor, product, serial, vid, pid, driver, usable, error}]
  port = ports.filter(function (p) { return p.usable; })[0];
  if (!port) throw new Error('no serial port');
  return Arda.serial.open(port.id, { baud: 9600, dataBits: 8, parity: 'none', stopBits: 1 });
}).then(function () {
  return Arda.serial.write(port.id, new Uint8Array([0x57, 0x0d]));      // "W\r"
}).then(function () {
  return Arda.serial.read(port.id, { max: 64, timeoutMs: 1500 });
}).then(function (reply) {
  if (!reply.length) return say('The scale did not answer');           // silence is an answer
  say('Weight: ' + new TextDecoder().decode(reply).trim());
}).catch(function (e) { say('Scale: ' + e.message); });
  • Use id (a stable name), never path: ttyUSB0 and ttyUSB1 swap places depending on which adapter was plugged in first.
  • open defaults to 9600 8N1. The port is raw: no echo, no line editing, no CR/LF translation; binary passes byte for byte.
  • read resolves a Uint8Array, empty if nothing came in time — not an error.
  • close(id) when you are done.

Checked: call names, options and status fields are from ArdaJS() in the player source; printers.list() answering an empty list on a machine with no printer was observed. The calls are shaped like those of an app printing on a device today. The printed output and the serial exchange were not verified against a device here. The network calls were run against a Wi-Fi receipt printer: found by the sweep, added, and printed to.