Integrasi JavaScript (Web Browser) dengan UHF Reader

Kini, dengan perkembangan teknologi web, JavaScript di browser pun dapat mengakses perangkat serial melalui API modern yang disebut Web Serial API. Salah satu metode utama dalam API ini adalah navigator.serial, yang memungkinkan aplikasi web untuk berkomunikasi dengan perangkat serial secara langsung tanpa perlu aplikasi tambahan.

navigator.serial adalah bagian dari Web Serial API yang memungkinkan aplikasi web untuk mengakses dan berkomunikasi dengan reader Electron yang terhubung melalui port serial, dengan API ini kita dapat mendeteksi perangkat, read & write data bytes.

Tutorial ini hanya berfungsi untuk komunikasi melalui Serial RS-232. JavaScript (web browser) tidak mendukung koneksi TCP Socket, sehingga komunikasi langsung melalui TCP/IP (LAN/Ethernet) tidak dapat dilakukan.

Kode

Kode ini berjalan di mode Answer Mode, silahkan buka program demo, ubah terlebih dulu ke mode Answer Mode, tutorial ada di Jenis Work Mode UHF Reader.

Kode JavaScript yang dibuat dapat disesuai kebutuhan, pada tutorial ini kami menggunakan Reader HW-VX6336 (HW-VX63 Series), sehingga kode JavaScript disesuaikan dengan dokumentasi protokol Reader HW-VX63 Series.

Untuk menggunakan reader jenis EL-UHF-RC4 Series, contoh kode JavaScript dapat Anda download download di: https://cloud.electron.id/s/wSap3C2PiWXz4Jj

Folder tree

.
├── index.html
└── serial
    ├── command.js
    ├── index.js
    ├── reader.js
    ├── response.js
    ├── transport.js
    └── utils.js

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>HW-VX Series</title>
    <link
      rel="stylesheet"
      href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css"
    />
    <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
  </head>
 
  <body>
    <div id="app" class="container mt-3">
      <h1 class="text-center">(Serial) HW-VX Series - Answer Mode</h1>
 
      <div class="text-center mb-2">
        <p>
          Status:
          <span :class="isOpen ? 'text-success' : 'text-danger'"
            >{{ deviceStatus }}</span
          >
        </p>
        <p v-if="deviceInfoText">
          Connected Device:
          <span class="text-primary">{{ deviceInfoText }}</span>
        </p>
        <p v-if="!supportsSerial" class="text-danger">
          Your browser does not support Web Serial API (use Chrome/Edge over
          HTTPS or http://localhost).
        </p>
      </div>
 
      <div class="row justify-content-center mb-4">
        <div class="col-auto">
          <button
            class="btn"
            :class="deviceStatus === 'Open' ? 'btn-danger' : 'btn-primary'"
            @click="togglePort"
          >
            {{ deviceStatus === 'Open' ? 'Close' : 'Open' }}
          </button>
        </div>
        <div class="col-auto">
          <button
            class="btn"
            :class="scanning ? 'btn-warning' : 'btn-success'"
            @click="toggleScan"
            :disabled="!isOpen || busy"
          >
            {{ scanning ? 'Stop' : 'Start' }}
          </button>
        </div>
      </div>
 
      <!-- Table -->
      <div>
        <h4>Received Tags:</h4>
        <table class="table table-striped">
          <thead>
            <tr>
              <th style="width: 80px">No.</th>
              <th>Tag (Hex)</th>
              <th style="width: 120px">Count</th>
            </tr>
          </thead>
          <tbody>
            <tr v-for="(row, idx) in tagsList" :key="row.hex">
              <td>{{ idx + 1 }}</td>
              <td class="font-monospace">{{ row.hex }}</td>
              <td>{{ row.count }}</td>
            </tr>
            <tr v-if="tagsList.length === 0">
              <td colspan="4" class="text-muted">No tags yet.</td>
            </tr>
          </tbody>
        </table>
      </div>
    </div>
 
    <script type="module">
      import { toHex } from "./serial/utils.js";
      import { SerialTransport } from "./serial/transport.js";
      import { Reader } from "./serial/reader.js";
 
      const { createApp, reactive, computed, onBeforeUnmount } = Vue;
      const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
 
      createApp({
        setup() {
          const supportsSerial = "serial" in navigator;
 
          const state = reactive({
            transport: new SerialTransport(),
            reader: null,
            isOpen: false,
            scanning: false,
            busy: false,
            deviceInfo: null,
            tagMap: reactive(new Map()),
          });
 
          const deviceStatus = computed(() =>
            state.isOpen ? "Open" : "Closed"
          );
          const deviceInfoText = computed(() => {
            const i = state.deviceInfo;
            if (!i) return "";
            const vid =
              i.usbVendorId != null
                ? i.usbVendorId.toString(16).toUpperCase()
                : "—";
            const pid =
              i.usbProductId != null
                ? i.usbProductId.toString(16).toUpperCase()
                : "—";
            return `VID=0x${vid} PID=0x${pid}`;
          });
          const tagsList = computed(() =>
            Array.from(state.tagMap.entries())
              .map(([hex, info]) => ({
                hex,
                count: info.count,
              }))
              .sort((a, b) => b.count - a.count)
          );
 
          async function togglePort() {
            if (state.busy) return;
            state.busy = true;
            try {
              if (state.isOpen) {
                console.log("Closing port...");
                state.scanning = false;
                await sleep(50);
                await state.transport.close();
                state.reader = null;
                state.deviceInfo = null;
                state.isOpen = false;
              } else {
                await state.transport.open({
                  baudRate: 57600,
                });
                state.reader = new Reader(state.transport);
                state.deviceInfo = state.transport.getDeviceInfo();
                state.isOpen = true;
              }
            } catch (err) {
              console.error("togglePort error:", err);
              alert("Port error: " + (err?.message ?? err));
            } finally {
              state.busy = false;
            }
          }
 
          function updateTags(newTags) {
            if (!newTags || newTags.length === 0) {
              return;
            }
 
            newTags.forEach((tag) => {
              const tagHex = toHex(tag);
              const cur = state.tagMap.get(tagHex);
              if (!cur) {
                state.tagMap.set(tagHex, { count: 1 });
              } else {
                state.tagMap.set(tagHex, { count: cur.count + 1 });
              }
            });
          }
 
          async function toggleScan() {
            console.log("Toggling scan...");
            if (!state.isOpen || state.busy) return;
 
            if (state.scanning) {
              state.scanning = false;
              return;
            }
 
            state.scanning = true;
            state.tagMap.clear();
 
            while (state.scanning) {
              try {
                const newTags = await state.reader.inventoryAnswerMode();
                updateTags(newTags); // Update tag counts
 
                await new Promise((r) => setTimeout(r, 100));
              } catch (error) {
                console.error("Error during inventory:", error);
                alert("Error during inventory process.");
                state.scanning = false; // Stop on error
              }
            }
          }
 
          onBeforeUnmount(async () => {
            try {
              state.scanning = false;
              await state.transport.close();
              console.log("Port closed on unload.");
            } catch {}
          });
          window.addEventListener("beforeunload", async () => {
            try {
              state.scanning = false;
              await state.transport.close();
              console.log("Port closed on unload.");
            } catch {}
          });
 
          return {
            supportsSerial,
            state,
            isOpen: computed(() => state.isOpen),
            scanning: computed(() => state.scanning),
            busy: computed(() => state.busy),
            deviceStatus,
            deviceInfoText,
            tagsList,
            updateTags,
            togglePort,
            toggleScan,
          };
        },
      }).mount("#app");
    </script>
  </body>
</html>

serial/command.js

import { calculateChecksum } from "./utils.js";
 
export class Command {
  /**
   * @param {number} command
   * @param {number} [address=0xFF]
   * @param {Uint8Array | number[]} [data=[]]
   */
  constructor(command, address = 0xff, data = []) {
    this.command = command;
    this.address = address;
    this.data = data instanceof Uint8Array ? data : new Uint8Array(data);
  }
 
  /**
   * Build the binary frame.
   * @param {boolean} [withChecksum=true]
   * @returns {Uint8Array}
   */
  serialize(withChecksum = true) {
    const base = [];
 
    // FRAME LENGTH
    base.push(4 + this.data.length);
 
    // ADDRESS
    base.push(this.address);
 
    // COMMAND
    base.push(this.command);
 
    // DATA (optional payload)
    base.push(...this.data);
 
    let frame = new Uint8Array(base);
 
    // CHECKSUM
    if (withChecksum) {
      const checksumValue = calculateChecksum(frame);
 
      const checksumBytes = new Uint8Array([
        checksumValue & 0xff,
        (checksumValue >> 8) & 0xff,
      ]);
 
      const full = new Uint8Array(frame.length + 2);
      full.set(frame, 0);
      full.set(checksumBytes, frame.length);
 
      frame = full;
    }
 
    return frame;
  }
}
 

serial/index.js

export * from "./utils.js";
export * from "./transport.js";
export * from "./response.js";
export * from "./reader.js";
export * from "./command.js";

serial/reader.js

import { toHex } from "./utils.js";
import { Response } from "./response.js";
import { Command } from "./command.js";
 
export class Reader {
  /**
   * @param {import('./transport.js').SerialTransport} transport
   */
  constructor(transport) {
    this.transport = transport;
  }
 
  async request(command) {
    await this.transport.send(command.serialize());
    console.log("Sent command:", toHex(command.serialize()));
  }
 
  async receive() {
    // 1) Read length
    const firstByte = await this.transport.read(1);
    if (firstByte.length != 1) {
      console.error("receive(): byte length missing");
      return null;
    }
 
    // 2) Read body
    const needBody = firstByte?.[0]; // CRC-16 (2 byte)
    const dataBytes = await this.transport.read(needBody);
    if (!dataBytes || dataBytes.length !== needBody) {
      this.transport.clear();
      console.error(
        `receive(): body incomplete (got ${
          dataBytes?.length ?? 0
        } of ${needBody}), body: ${toHex(dataBytes)}`
      );
      return null;
    }
    // 3) Frame
    const frame = new Uint8Array(firstByte.byteLength + dataBytes.byteLength);
    frame.set(firstByte);
    frame.set(dataBytes, firstByte.byteLength);
 
    return new Response(frame);
  }
 
  async inventoryAnswerMode() {
    const cmd = new Command(
      0x01 // Command code
    );
    console.log("Starting inventoryAnswerMode()...");
 
    try {
      await this.request(cmd);
 
      const resp = await this.receive();
      if (!resp) {
        console.warn("inventoryAnswerMode(): receive() returned null.");
        return;
      }
 
      const data = resp.data ?? new Uint8Array(0);
 
      if (data.length === 0) {
        return [];
      }
 
      const tags = [];
      const tagCount = data[0]; // The first byte indicates the number of tags
 
      let n = 0;
      let pointer = 1; // Start reading after the tagCount byte
 
      while (n < tagCount) {
        const tagLen = data[pointer]; // Length of the tag
        const tagDataStart = pointer + 1; // Start of the tag data
        const tagMainStart = tagDataStart; // Start of the main tag
        const tagMainEnd = tagMainStart + tagLen; // End of the main tag
        const nextTagStart = tagMainEnd; // Start of the next tag
 
        // Create a new Uint8Array for the tag
        const tag = new Uint8Array([
          ...data.subarray(tagDataStart, tagMainStart),
          ...data.subarray(tagMainStart, tagMainEnd),
          ...data.subarray(nextTagStart, nextTagStart),
        ]);
 
        if (tag.length == 0) {
          break;
        }
 
        tags.push(tag); // Add the tag to the list
 
        console.log(
          "Tag ke-" +
            (n + 1) +
            ": " +
            Array.from(tag)
              .map((byte) => byte.toString(16).padStart(2, "0").toUpperCase())
              .join(" ")
        );
 
        pointer = nextTagStart; // Move the pointer to the next tag
        n += 1; // Increment the tag count
      }
 
      return tags;
    } catch (e) {
      console.error("inventoryAnswerMode() error:", e);
      return;
    }
  }
}

serial/response.js

import { calculateChecksum } from "./utils.js";
 
export class Response {
  /**
   * @param {Uint8Array} frame
   */
  constructor(frame) {
    if (!(frame instanceof Uint8Array))
      throw new TypeError("Response expects Uint8Array");
    if (frame.length < 6) throw new Error("Frame too short.");
 
    this.bytes = frame;
    this.length = frame[0];
    this.readerAddress = frame[1];
    this.command = frame[2];
    this.status = frame[3];
    this.data = frame.slice(4, -2);
    this.checksum = frame.slice(-2);
  }
 
  get checksumValue() {
    return (this.checksum[1] << 8) | this.checksum[0];
  }
 
  validateChecksum() {
    const calculatedChecksum = calculateChecksum(this.bytes.slice(0, -2));
    return this.checksumValue === calculatedChecksum;
  }
}

serial/transport.js

import { delay } from "./utils.js";
 
export class SerialTransport {
  port = null;
  reader = null;
  writer = null;
 
  async open({
    baudRate = 115_200,
    bufferSize = 1_024,
    dataBits = 8,
    stopBits = 1,
    parity = "none",
    flowControl = "none",
    filters,
    requestPort = true,
  } = {}) {
    if (!this.port) {
      this.port = requestPort
        ? await navigator.serial.requestPort(filters ? { filters } : undefined)
        : (await navigator.serial.getPorts())[0] ?? null;
      if (!this.port) throw new Error("No serial port");
    }
 
    if (!this.port.readable || !this.port.writable) {
      await this.port.open({
        baudRate,
        bufferSize,
        dataBits,
        stopBits,
        parity,
        flowControl,
      });
    }
 
    try {
      await this.port.setSignals?.({
        dataTerminalReady: true,
        requestToSend: true,
      });
    } catch {}
    await delay(30);
 
    this.reader = this.port.readable?.getReader({ mode: "byob" }) ?? null;
    this.writer = this.port.writable?.getWriter() ?? null;
 
    console.log("Serial opened:", this.getDeviceInfo() || "unknown device");
  }
 
  async close() {
    try {
      if (this.reader) {
        this.reader.releaseLock();
        this.reader = null;
      }
      if (this.writer) {
        await this.writer.close().catch(() => {});
        this.writer.releaseLock();
        this.writer = null;
      }
      if (this.port) {
        await this.port.close().catch(() => {});
      }
    } finally {
      this.port = null;
    }
  }
 
  getDeviceInfo() {
    if (!this.port?.getInfo) return null;
    const { usbVendorId, usbProductId } = this.port.getInfo();
    return { usbVendorId, usbProductId };
  }
 
  async send(data, { chunkSize = 0, interChunkMs = 0 } = {}) {
    if (!this.writer) throw new Error("Port not writable");
    if (!chunkSize || chunkSize <= 0) {
      await this.writer.write(data);
      return;
    }
    for (let off = 0; off < data.length; off += chunkSize) {
      const slice = data.subarray(off, Math.min(off + chunkSize, data.length));
      await this.writer.write(slice);
      if (interChunkMs) await delay(interChunkMs);
    }
  }
 
  async _receiveChunk(size) {
    if (!this.reader) throw new Error("Reader not initialized");
    if (size <= 0) return new Uint8Array(0);
 
    // BYOB: provide your own buffer for efficient reads
    const byob = new Uint8Array(size);
    const { value, done } = await this.reader.read(byob);
    if (done) return new Uint8Array(0);
 
    // `value` is a view over `byob` cropped to the actual bytes read
    return value?.byteLength === byob.byteLength ? byob : value.slice();
  }
 
  async read(size, { interChunkIdleMs = 20 } = {}) {
    const chunks = [];
    let received = 0;
 
    while (received < size) {
      // Ask only for what's still missing, cap to keep snappy
      const want = Math.min(1024, size - received);
      try {
        const chunk = await this._receiveChunk(want);
        if (chunk.byteLength === 0) {
          // No bytes this turn → brief idle, then retry
          await delay(interChunkIdleMs);
          continue;
        }
        chunks.push(chunk);
        received += chunk.byteLength;
      } catch (e) {
        console.error("receiveExact() read error:", e);
        return null;
      }
    }
 
    // Fast path: single chunk already complete
    if (chunks.length === 1) return chunks[0];
 
    // Combine chunks into a single contiguous buffer
    const out = new Uint8Array(size);
    let off = 0;
    for (const c of chunks) {
      out.set(c, off);
      off += c.byteLength;
    }
    return out;
  }
 
  async clear() {
    if (!this.reader) return;
    const buf = await this.read(512);
    if (!buf || buf.length === 0) console.log("No data in buffer.");
    else console.log("Buffer cleared:", buf);
  }
}

serial/utils.js

/** Sleep for `ms` milliseconds (used for small idle/backoff waits). */
export const delay = (ms) => new Promise((res) => setTimeout(res, ms));
 
/** Hex-print helper (e.g., for debugging frames). */
export function toHex(bytes) {
  return Array.from(bytes)
    .map((b) => b.toString(16).padStart(2, "0").toUpperCase())
    .join(" ");
}
 
export function calculateChecksum(data) {
  let value = 0xffff;
  for (let byte of data) {
    value ^= byte;
    for (let i = 0; i < 8; i++) {
      value = (value & 0x0001) !== 0 ? (value >> 1) ^ 0x8408 : value >> 1;
    }
  }
  return value;
}
 

Demo

HW-VX63 Series

EL-UHF-RC4 Series

Telah di uji pada browser:

  • Chrome: Version 138.0.7204.183 (Official Build) (64-bit)
  • Brave: Version 1.73.104 Chromium: 131.0.6778.204 (Official Build) (64-bit)

Troubleshooting

  1. Jika tag tidak tampil, coba pastikan reader telah berjalan di Baud Rate 57600 bps (atur menggunakan program demo).
  2. Pastikan reader sedang berjalan di mode Answer Mode.
  3. Saat debug muncul pesan error NetworkError: The device has been lost., solusinya cabut dan colok ulang kabel Serial RS232.

Contoh error: NetworkError: The device has been lost.