Skip to content

Complete connection and simulation example ​

This walkthrough runs from your own application with the public package installed. You do not need the private source repository, build tools or publishing permissions. If needed, follow installation first.

The installed jsd-example command runs a complete application: connection, readiness, event listeners, commands, errors and cleanup. You can then copy its full script and adapt it to your application.

1. Run without hardware ​

Use Node.js 20.6+ for the simulator. Choose your package manager:

sh
npx --no-install jsd-example simulate
sh
pnpm exec jsd-example simulate
sh
bun run jsd-example simulate
sh
yarn jsd-example simulate

All four commands execute Node. The simulator uses a virtual port and never opens a physical device. It queries status, version, identification, temperature, license, seed, MDB state and paginated logs, then performs a simulated dispense. It closes and exits automatically.

Expected output includes:

text
SIMULATION: no physical port will be opened.
[serial:status] SCANNING
[serial:status] CONNECTING
[serial:status] CONNECTED
[serial:connected] { path: 'JSD-SIMULATOR', baudRate: 9600 }
...
[vision:dispense-status] ... status: 'COLLECTED' ...
[result] { delivered: true }
Simulation finished.
[serial:disconnected] Manual

Automatic status queries for machines 1–4 can interleave with command responses. A shutdown:notice can indicate a final ACK canceled by closing; a physical close failure is reported separately and sets a failure exit code.

2. Explore commands ​

Start an interactive virtual session:

sh
npx --no-install jsd-example simulate --interactive

For other package managers, replace their simulate command from step 1 with simulate --interactive.

Type help, status, version, logs, channels, dispense 1, then quit.

CommandWhat happens
helpList supported commands
statusQuery the selected machine and filter its response by number
versionRequest the JSD software version
idRequest machine identity
temperatureRequest temperature readings
licenseRequest temporary/permanent license status
seedPrint seed bytes and Base64; does not activate a license
mdbQuery card-holder 1 status; does not execute a payment
logsRead pages until END_LOG
channelsVerify all 80 selections and show progress
dispense 1Dispense selection 1 when explicitly enabled
quitAwait disconnection and release the client

The console accepts a fixed command list. It never evaluates arbitrary JavaScript.

3. Observe a dispense failure ​

sh
JSD_SIM_SCENARIO=failure npx --no-install jsd-example simulate
powershell
$env:JSD_SIM_SCENARIO = "failure"
npx --no-install jsd-example simulate
Remove-Item Env:JSD_SIM_SCENARIO

This emits FAILED-DISPENSE and returns { delivered: false }. A device-reported failure is different from an exception or a lost connection.

4. Connect real hardware ​

List ports without opening them:

sh
npx --no-install jsd-example ports

Then configure the path and baud rate for your device:

sh
JSD_PORT=/dev/ttyUSB0 JSD_BAUD_RATE=9600 npx --no-install jsd-example connect
sh
JSD_PORT=/dev/cu.usbserial-YOUR_DEVICE JSD_BAUD_RATE=9600 npx --no-install jsd-example connect
powershell
$env:JSD_PORT = "COM3"
$env:JSD_BAUD_RATE = "9600"
npx --no-install jsd-example connect

For pnpm, use pnpm exec jsd-example connect; for Bun, bun run jsd-example connect; for Yarn, yarn jsd-example connect. Set environment variables as shown above.

Real hardware starts read-only. Try status, version, id and temperature. Serial readiness is separate from machine availability.

To use USB discovery, unset JSD_PORT and supply selectors instead:

sh
JSD_VENDOR_ID=0403 JSD_PRODUCT_ID=6001 npx --no-install jsd-example connect

Environment settings ​

VariableDefaultMeaning
JSD_PORTnoneExplicit path; otherwise supply a USB selector
JSD_VENDOR_IDnoneExact USB vendor selector
JSD_PRODUCT_IDnoneExact USB product selector
JSD_SERIAL_NUMBERnoneExact unit serial number
JSD_BAUD_RATE9600Positive integer serial speed
JSD_MACHINE1Machine 1–4
JSD_DEBUG0Set 1 to print parsed frames and aliases
JSD_ALLOW_DISPENSE0 for hardwareSet 1 to enable deliberate dispensing
JSD_SIM_SCENARIOsuccessSimulation only: success or failure

For deliberate hardware dispensing on Linux/macOS:

sh
JSD_PORT=/dev/ttyUSB0 JSD_ALLOW_DISPENSE=1 npx --no-install jsd-example connect

In PowerShell set $env:JSD_ALLOW_DISPENSE = "1" before connecting. You must still type dispense <1-80>. The script refreshes machine status and checks availability first. The simulator opts in automatically; use 0 to practice with dispensing disabled.

5. Adapt the example to your application ​

The script below is standalone: save it as connect.mjs in your application and execute node connect.mjs with the same environment variables. It imports only the installed package and Node's standard library.

The package contains a copy at node_modules/@danidoble/serial-node-jsd/examples/connect.mjs. You can read or copy that file without access to the private library repository. The virtual-device files are included too; hardware mode does not load them.

The important sequence is:

  1. Create JSD with autoConnect: false and register listeners first.
  2. Subscribe to serial:connected before start(), which returns void.
  3. Read callback payloads directly; there is no browser event.detail.
  4. Subscribe before submitting a command. A resolved submission promise is not its device response.
  5. Bound waits: the example allows 5 seconds for queries and 60 for aggregate operations. A timeout does not undo a physical operation; the script retires the session after a timed-out interactive command.
  6. Close with quit, Ctrl+C, SIGTERM or EOF. Always await stop() and remove listeners your application owns.

Complete application script ​

The JSDoc types give IDE completion while keeping the script directly runnable as JavaScript.

js
/**
 * Complete Node.js starter. Uses only the public package API.
 * Copy to your application as connect.mjs, then set JSD_PORT and run node connect.mjs.
 * Installed simulation: npx --no-install jsd-example simulate
 */
import { once } from 'node:events';
import { createInterface } from 'node:readline/promises';
import { stdin, stdout } from 'node:process';
import { JSD, SerialStatus } from '@danidoble/serial-node-jsd';

/** @typedef {import('@danidoble/serial-node-jsd').JSDEvents} JSDEvents */
/** @typedef {import('@danidoble/serial-node-jsd').MachineValid} MachineValid */

const demo = process.argv.includes('--demo');
const simulation = process.env.JSD_SIMULATION === '1' && process.env.JSD_PORT === 'JSD-SIMULATOR';
const allowDispense = process.env.JSD_ALLOW_DISPENSE === '1';
const timeout = 5_000;
const operationTimeout = 60_000;

/** @param {string} label @param {string | undefined} value @param {number} fallback */
function positiveInteger(label, value, fallback) {
  const number = value === undefined ? fallback : Number(value);
  if (!Number.isSafeInteger(number) || number <= 0) throw new Error(`${label} must be a positive integer`);
  return number;
}

// Listing never opens a connection, and works without setting JSD_PORT.
if (process.argv.includes('--list')) {
  try {
    console.table(await JSD.listPorts());
  } catch (error) {
    console.error(error);
    process.exitCode = 1;
  }
} else {
  try {
    await main();
  } catch (error) {
    console.error('[fatal]', error instanceof Error ? error.message : error);
    process.exitCode = 1;
  }
}

async function main() {
  if (demo && !simulation) throw new Error('--demo is reserved for the virtual-port example. Use jsd-example simulate.');
  if (!process.env.JSD_PORT && !process.env.JSD_VENDOR_ID && !process.env.JSD_PRODUCT_ID && !process.env.JSD_SERIAL_NUMBER) {
    throw new Error('Set JSD_PORT (e.g. /dev/ttyUSB0 or COM3), or a USB selector. Use --list to discover ports.');
  }
  const machineNumber = positiveInteger('JSD_MACHINE', process.env.JSD_MACHINE, 1);
  if (machineNumber > 4) throw new Error('JSD_MACHINE must be between 1 and 4');
  const machine = /** @type {MachineValid} */ (machineNumber);

  /** @type {import('@danidoble/serial-node-jsd').JSDConfig} */
  const config = {
    path: process.env.JSD_PORT || undefined,
    vendorId: process.env.JSD_VENDOR_ID || undefined,
    productId: process.env.JSD_PRODUCT_ID || undefined,
    serialNumber: process.env.JSD_SERIAL_NUMBER || undefined,
    baudRate: positiveInteger('JSD_BAUD_RATE', process.env.JSD_BAUD_RATE, 9600),
    autoConnect: false, // Attach listeners first. start() returns void, not readiness.
    autoReconnect: true,
    reconnectInterval: 1_000,
    reconnectBackoffFactor: 2,
    maxReconnectInterval: 5_000,
    maxReconnectAttempts: 3,
    writeTimeout: 3_000
  };
  const jsd = new JSD(config, { transport: 'rs232' });
  /** @type {ReturnType<typeof createInterface> | undefined} */
  let terminal;
  let stopping = false;
  const lifetime = new AbortController();
  /** @type {Promise<void> | undefined} */
  let closePromise;

  // Event callbacks receive payloads directly. There is no browser event.detail.
  jsd.on('serial:status', status => console.log('[serial:status]', status));
  jsd.on('serial:connected', info => console.log('[serial:connected]', info));
  jsd.on('serial:disconnected', reason => console.log('[serial:disconnected]', reason));
  jsd.on('serial:reconnecting', info => console.warn('[serial:reconnecting]', info));
  jsd.on('serial:reconnectFailed', info => {
    console.error('[serial:reconnectFailed]', info);
    process.exitCode = 1;
    void shutdown();
  });
  jsd.on('serial:error', error => {
    // stop() may cancel an ACK still draining. Closure failures are handled below.
    if (stopping) console.log('[shutdown:notice]', error.message);
    else console.error('[serial:error]', error.message);
  });
  jsd.on('serial:jsd-error', message => console.error('[serial:jsd-error]', message));
  jsd.on('serial:jsd-nack', frame => console.warn('[serial:jsd-nack]', frame));
  if (process.env.JSD_DEBUG === '1') {
    jsd.on('serial:message', message => console.log('[serial:message]', message.alias, message.parsed));
  }
  jsd.on('vision:machine-status', data => console.log('[vision:machine-status]', data));
  jsd.on('vision:door', data => console.log('[vision:door]', data));
  jsd.on('vision:connection', data => console.log('[vision:connection]', data));
  jsd.on('vision:machine-id', data => console.log('[vision:machine-id]', data));
  jsd.on('vision:jsd-version', data => console.log('[vision:jsd-version]', data));
  jsd.on('vision:current-temperature', data => console.log('[vision:current-temperature]', data));
  jsd.on('vision:dispensing', data => console.log('[vision:dispensing]', data));
  jsd.on('vision:dispense-status', data => console.log('[vision:dispense-status]', data));
  jsd.on('vision:waiting-collection', data => console.log('[vision:waiting-collection]', data));
  jsd.on('vision:channels-progress', data => console.log('[vision:channels-progress]', data));
  jsd.on('vision:wrong-cmd', data => console.error('[vision:wrong-cmd]', data));
  jsd.on('vision:jsd-license-error', data => console.error('[vision:jsd-license-error]', data));
  jsd.on('licensing:temporary-license-status', data => console.log('[licensing:temporary-license-status]', data));
  jsd.on('licensing:identification', data => console.log('[licensing:identification]', data));
  jsd.on('licensing:seed', data => console.log('[licensing:seed]', { base64: data.base64, bytes: data.bytes.length }));
  jsd.on('manifest:log', data => console.log('[manifest:log]', data));
  jsd.on('mdbCardHolder:condition-status', data => console.log('[mdbCardHolder:condition-status]', data));

  // An application deadline stops this example's wait. It does not undo a vend.
  // If a command result is ambiguous, reconcile the device before trying again.
  /** @template T @param {Promise<T>} operation @returns {Promise<T>} */
  function bounded(operation) {
    /** @type {ReturnType<typeof setTimeout> | undefined} */
    let timer;
    /** @type {Promise<never>} */
    const deadline = new Promise((_, reject) => {
      timer = setTimeout(() => reject(new Error('Application deadline exceeded; outcome may be unknown')), operationTimeout);
    });
    return Promise.race([operation, deadline]).finally(() => clearTimeout(timer));
  }

  /**
   * Subscribe BEFORE sending. A resolved Promise<void> only confirms submission.
   * @template {keyof JSDEvents} K
   * @param {K} event
   * @param {() => Promise<void> | void} send
   * @returns {Promise<JSDEvents[K][0]>}
   */
  async function request(event, send) {
    const abort = new AbortController();
    const onStop = () => abort.abort(lifetime.signal.reason);
    lifetime.signal.addEventListener('abort', onStop, { once: true });
    if (lifetime.signal.aborted) onStop();
    const timer = setTimeout(() => abort.abort(new Error(`Timed out waiting for ${event}`)), timeout);
    const response = once(jsd, event, { signal: abort.signal });
    try {
      const [args] = await Promise.all([response, Promise.resolve().then(send)]);
      return args[0];
    } finally {
      clearTimeout(timer);
      lifetime.signal.removeEventListener('abort', onStop);
      abort.abort();
    }
  }

  // Filter by machine: automatic startup queries can produce events for 1–4.
  function status() {
    /** @type {Promise<JSDEvents['vision:machine-status'][0]>} */
    const result = new Promise((resolve, reject) => {
      /** @param {JSDEvents['vision:machine-status'][0]} data */
      const onStatus = data => {
        if (data.machine === machine) {
          cleanup();
          resolve(data);
        }
      };
      const onLost = () => {
        cleanup();
        reject(new Error('Session lost while waiting for machine status'));
      };
      const timer = setTimeout(() => {
        cleanup();
        reject(new Error('Machine status deadline exceeded'));
      }, timeout);
      function cleanup() {
        clearTimeout(timer);
        jsd.off('vision:machine-status', onStatus);
        jsd.off('serial:disconnected', onLost);
        lifetime.signal.removeEventListener('abort', onLost);
      }
      jsd.on('vision:machine-status', onStatus);
      jsd.once('serial:disconnected', onLost);
      lifetime.signal.addEventListener('abort', onLost, { once: true });
      void jsd.vision.requestMachineStatus({ machine }).catch(error => {
        cleanup();
        reject(error);
      });
    });
    return result;
  }

  async function shutdown() {
    if (closePromise) return closePromise;
    stopping = true;
    lifetime.abort(new Error('Example stopped'));
    terminal?.close();
    closePromise = jsd.stop().catch(error => {
      console.error('[shutdown]', error);
      process.exitCode = 1;
    });
    return closePromise;
  }
  const onSignal = () => {
    void shutdown();
  };
  process.once('SIGINT', onSignal);
  process.once('SIGTERM', onSignal);

  try {
    console.log(simulation ? 'SIMULATION: no physical port will be opened.' : 'REAL HARDWARE: read-only commands are enabled by default.');
    await request('serial:connected', () => jsd.start());
    if (stopping) return;
    console.log('Serial session ready. Machine readiness is checked separately.');

    /** @param {string} line */
    async function execute(line) {
      const [command = 'help', argument] = line.trim().split(/\s+/);
      if (jsd.devicePortStatus() !== SerialStatus.CONNECTED && !['help', 'quit'].includes(command)) {
        throw new Error('Serial session is not ready; wait for serial:connected');
      }
      switch (command) {
        case 'status':
          return status();
        case 'version':
          return request('vision:jsd-version', () => jsd.vision.requestJSDVersion());
        case 'id':
          return request('vision:machine-id', () => jsd.vision.requestMachineIdentification({ machine }));
        case 'temperature':
          return request('vision:current-temperature', () => jsd.vision.requestWorkingTemperature({ machine }));
        case 'license':
          return request('licensing:temporary-license-status', () => jsd.licensing.requestTemporaryLicenseStatus());
        case 'seed':
          return request('licensing:seed', () => jsd.licensing.requestSeedData());
        case 'mdb':
          return request('mdbCardHolder:condition-status', () => jsd.mdbCardHolder.requestStatus({ deviceNumber: 1 }));
        case 'logs':
          return bounded(jsd.manifest.requestLogs());
        case 'channels':
          return bounded(jsd.vision.assignChannels({ machine }));
        case 'dispense': {
          if (!allowDispense) throw new Error('Dispensing is disabled. Set JSD_ALLOW_DISPENSE=1 explicitly to enable it.');
          if (argument === undefined) throw new Error('Usage: dispense <selection 1-80>');
          const selection = positiveInteger('selection', argument, 1);
          if (selection > 80) throw new Error('Selection must be between 1 and 80');
          await status();
          if (!jsd.vision.isAvailableToDispense({ machine })) throw new Error('Machine is not available to dispense');
          const delivered = await bounded(
            jsd.vision.dispense({ machine, selection: /** @type {import('@danidoble/serial-node-jsd').Range0To81} */ (selection) })
          );
          return { delivered };
        }
        case 'quit':
          await shutdown();
          return 'Stopped';
        case 'help':
          return 'status | version | id | temperature | license | seed | mdb | logs | channels | dispense <1-80> | quit';
        default:
          throw new Error(`Unknown command: ${command}. Type help.`);
      }
    }

    if (demo) {
      for (const command of ['status', 'version', 'id', 'temperature', 'license', 'seed', 'mdb', 'logs', 'dispense 1']) {
        if (stopping) break;
        console.log(`\n> ${command}`);
        console.log('[result]', await execute(command));
      }
      console.log('Simulation finished.');
    } else {
      console.log(await execute('help'));
      terminal = createInterface({ input: stdin, output: stdout, terminal: Boolean(stdin.isTTY) });
      terminal.setPrompt('jsd> ');
      terminal.prompt();
      for await (const line of terminal) {
        if (stopping) break;
        try {
          console.log('[result]', await execute(line));
        } catch (error) {
          console.error('[command]', error instanceof Error ? error.message : error);
          // Abandoning a timed-out operation requires retiring its session.
          if (error instanceof Error && /deadline|Timed out/.test(error.message)) {
            await shutdown();
            break;
          }
        }
        if (stopping) break;
        terminal.prompt();
      }
    }
  } catch (error) {
    if (!stopping) throw error;
  } finally {
    await shutdown();
    process.off('SIGINT', onSignal);
    process.off('SIGTERM', onSignal);
    jsd.removeAllListeners(); // This script owns this client and all its listeners.
  }
}

Simulation scope ​

The example's process uses serialport's public SerialPortMock through a Node module loader. The actual JSD client, serial-core, delimiter parser, CRC, queues and event routing still execute. The virtual device answers with valid ACKs and application frames, rather than manually emitting client events.

It implements the commands listed above with synthetic data. It is not an entire firmware, a license-activation service or a payment simulator. Unsupported commands produce a wrong-command response. Physical behavior still needs validation with the target machine.

Continue with Vision operations, all events or error handling.

Explicit protocol, typed events, isolated transport.