Saltar al contenido

Ejemplo completo de conexión y simulación ​

Este recorrido se ejecuta desde tu propia aplicación con el paquete público instalado. No necesitas el repositorio privado, herramientas de compilación ni permisos para publicar. Si hace falta, empieza por instalación.

El comando instalado jsd-example ejecuta una aplicación completa: conexión, disponibilidad, listeners, comandos, errores y limpieza. Después puedes copiar el script y adaptarlo a tu aplicación.

1. Ejecutar sin hardware ​

El simulador requiere Node.js 20.6+. Elige tu gestor:

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

Los cuatro comandos ejecutan Node. El simulador usa un puerto virtual y nunca abre equipos físicos. Consulta estado, versión, identificación, temperatura, licencia, semilla, estado MDB y registros paginados; después simula una entrega. Cierra y sale automáticamente.

La salida incluye:

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

Las consultas automáticas de las máquinas 1–4 pueden intercalarse con las respuestas. Un shutdown:notice puede indicar que cerrar canceló el ACK final; un fallo de cierre físico se informa aparte y establece un código de salida de error.

2. Explorar comandos ​

Abre una sesión virtual interactiva:

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

Con otros gestores, cambia su comando simulate del paso 1 por simulate --interactive.

Escribe help, status, version, logs, channels, dispense 1 y quit.

ComandoQué hace
helpLista comandos admitidos
statusConsulta la máquina seleccionada y filtra por su número
versionSolicita la versión del software JSD
idSolicita identificación de máquina
temperatureSolicita lecturas de temperatura
licenseConsulta licencia temporal o permanente
seedImprime bytes y Base64 de semilla; no activa licencias
mdbConsulta estado del lector 1; no ejecuta pagos
logsLee páginas hasta END_LOG
channelsVerifica las 80 selecciones y muestra progreso
dispense 1Dispensa la selección 1 si se habilitó explícitamente
quitEspera la desconexión y libera el cliente

La consola acepta una lista fija de comandos. Nunca evalúa JavaScript arbitrario.

3. Observar un dispensado fallido ​

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

Emite FAILED-DISPENSE y devuelve { delivered: false }. Un fallo informado por el equipo es distinto de una excepción o una desconexión.

4. Conectar hardware real ​

Enumera puertos sin abrirlos:

sh
npx --no-install jsd-example ports

Después configura ruta y velocidad:

sh
JSD_PORT=/dev/ttyUSB0 JSD_BAUD_RATE=9600 npx --no-install jsd-example connect
sh
JSD_PORT=/dev/cu.usbserial-TU_EQUIPO 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

Con pnpm usa pnpm exec jsd-example connect; con Bun, bun run jsd-example connect; con Yarn, yarn jsd-example connect. Configura las variables como en los bloques anteriores.

El hardware real empieza solo en lectura. Prueba status, version, id y temperature. La disponibilidad serial y la de la máquina son distintas.

Para descubrimiento USB, elimina JSD_PORT y proporciona selectores:

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

Variables de entorno ​

VariablePredeterminadoSignificado
JSD_PORTningunoRuta explícita; si falta, proporciona un selector USB
JSD_VENDOR_IDningunoFabricante USB exacto
JSD_PRODUCT_IDningunoProducto USB exacto
JSD_SERIAL_NUMBERningunoSerie exacta de la unidad
JSD_BAUD_RATE9600Velocidad serial entera positiva
JSD_MACHINE1Máquina 1–4
JSD_DEBUG0Usa 1 para imprimir tramas analizadas y alias
JSD_ALLOW_DISPENSE0 en hardwareUsa 1 para habilitar dispensado deliberado
JSD_SIM_SCENARIOsuccessSolo simulación: success o failure

Para dispensado deliberado en Linux/macOS:

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

En PowerShell configura $env:JSD_ALLOW_DISPENSE = "1" antes de conectar. Todavía debes escribir dispense <1-80>. El script vuelve a consultar el estado y verifica disponibilidad. El simulador lo habilita automáticamente; usa 0 para practicar con dispensado deshabilitado.

5. Adaptar el ejemplo a tu aplicación ​

El script siguiente es autónomo: guárdalo como connect.mjs en tu aplicación y ejecuta node connect.mjs con las mismas variables. Solo importa el paquete instalado y módulos estándar de Node.

El paquete incluye una copia en node_modules/@danidoble/serial-node-jsd/examples/connect.mjs. Puedes leerla o copiarla sin acceso al repositorio privado. Los archivos del equipo virtual también están incluidos; el modo físico no los carga.

La secuencia importante es:

  1. Crear JSD con autoConnect: false y registrar listeners primero.
  2. Escuchar serial:connected antes de start(), que devuelve void.
  3. Recibir datos directamente; no existe event.detail del navegador.
  4. Suscribirse antes de enviar. Una promesa de encolado resuelta no equivale a la respuesta del equipo.
  5. Limitar esperas: el ejemplo da 5 segundos a consultas y 60 a operaciones agregadas. Un plazo no deshace una entrega; el script cierra la sesión después de un comando interactivo que supera el límite.
  6. Cerrar con quit, Ctrl+C, SIGTERM o fin de entrada. Esperar siempre stop() y retirar los listeners que pertenecen a la aplicación.

Script completo de aplicación ​

Los tipos JSDoc permiten autocompletado del IDE y el archivo se ejecuta directamente como 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.
  }
}

Alcance de la simulación ​

El proceso del ejemplo usa la API pública SerialPortMock de serialport mediante un cargador Node. Siguen ejecutándose cliente JSD, serial-core, parser, CRC, colas y enrutamiento reales. El equipo virtual responde con ACKs y tramas válidas en lugar de emitir eventos del cliente manualmente.

Implementa los comandos indicados con datos inventados. No es un firmware completo, un servicio de activación de licencias ni un simulador de pagos. Los comandos no admitidos producen una respuesta de comando incorrecto. Valida el comportamiento físico con la máquina objetivo.

Continúa con operaciones Vision, todos los eventos o manejo de errores.

Protocolo explícito, eventos tipados y transporte aislado.