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.

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.

Jika menggunakan reader jenis lain, seperti EL-UHF-RC4 Series dapat disesuaikan class untuk pengiriman dan penerimaan data bytes berdasarkan format protokol reader tersebut.

Folder tree

.
├── index.html
└── reader.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>Serial Communication</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 Port Communication</h1>
        <div class="text-center">
            <p>
                Status: <span :class="deviceStatus === 'Open' ? 'text-success' : 'text-danger'">{{ deviceStatus
                    }}</span>
            </p>
            <p v-if="connectedDeviceName">
                Connected Device: <span class="text-primary">{{ connectedDeviceName }}</span>
            </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="insInventory ? 'btn-warning' : 'btn-success'" @click="toggleInventory"
                    :disabled="deviceStatus != 'Open'">
                    {{ insInventory ? 'Stop' : 'Start' }}
                </button>
            </div>
        </div>
        <div>
            <h4>Received Tags:</h4>
            <table class="table table-striped">
                <thead>
                    <tr>
                        <th>No.</th>
                        <th>Tag (Hex)</th>
                        <th>Count</th>
                    </tr>
                </thead>
                <tbody>
                    <tr v-for="(tag, index) in tags" :key="index">
                        <td>{{ index + 1 }}</td>
                        <td>{{ tagHex(tag.data) }}</td>
                        <td>{{ tag.count }}</td>
                    </tr>
                </tbody>
            </table>
        </div>
    </div>
 
 
    <script type="module">
        import { SerialTransport, Reader } from './reader.js';
 
        const app = Vue.createApp({
            data() {
                return {
                    transport: new SerialTransport(),
                    reader: null,
                    tags: [], // Store tags with counts
                    insInventory: false,
                    deviceStatus: "Closed",
                    connectedDeviceName: null // Name of the connected device
                };
            },
            methods: {
                async togglePort() {
                    if (this.deviceStatus === 'Open') {
                        // Close the port
                        try {
                            await this.transport.close();
                            this.deviceStatus = 'Closed';
                            this.connectedDeviceName = null;
                            this.insInventory = false; // Ensure inventory is stopped
                        } catch (error) {
                            console.error("Failed to close port:", error);
                            alert("Failed to close port.");
                        }
                    } else {
                        // Open the port
                        try {
                            await this.transport.open();
                            this.reader = new Reader(this.transport);
                            this.deviceStatus = 'Open';
                            this.connectedDeviceName = this.transport.getDeviceInfo();
                        } catch (error) {
                            console.error("Failed to open port:", error);
                            alert("Failed to open port. Please check the connection.");
                        }
                    }
                },
                async toggleInventory() {
                    if (this.insInventory) {
                        // Stop the inventory process
                        this.insInventory = false;
                    } else {
                        // Start the inventory process
                        this.tags = []; // Clear tags
                        this.insInventory = true;
                        while (this.insInventory) {
                            try {
                                const newTags = await this.reader.inventoryAnswerMode();
                                this.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.");
                                this.insInventory = false; // Stop on error
                            }
                        }
                    }
                },
                async openPort() {
                    try {
                        await this.transport.open();
                        this.reader = new Reader(this.transport);
 
                        // Update device status and name
                        this.deviceStatus = "Open";
                        this.connectedDeviceName = this.transport.getDeviceInfo();
                    } catch (error) {
                        console.error("Failed to open port:", error);
                        alert("Failed to open port. Please check the connection.");
                    }
                },
                async closePort() {
                    try {
                        await this.transport.close();
 
                        // Update device status and name
                        this.deviceStatus = "Closed";
                        this.connectedDeviceName = null;
                        this.insInventory = false;
                    } catch (error) {
                        console.error("Failed to close port:", error);
                        alert("Failed to close port.");
                    }
                },
                async start() {
                    // Clear tags
                    this.tags = [];
 
                    this.insInventory = true;
                    while (this.insInventory) {
                        try {
                            const newTags = await this.reader.inventoryAnswerMode();
                            this.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.");
                        }
                    }
                },
                stop() {
                    this.insInventory = false;
                },
                tagHex(tag) {
                    return Array.from(tag)
                        .map(byte => byte.toString(16).padStart(2, '0').toUpperCase())
                        .join(' '); // Convert tag to hex string
                },
                updateTags(newTags) {
                    if (!newTags || newTags.length === 0) {
                        return;
                    }
 
                    newTags.forEach(tag => {
                        const existingTag = this.tags.find(t => this.tagHex(t.data) === this.tagHex(tag));
                        if (existingTag) {
                            existingTag.count++; // Increment count if tag already exists
                        } else {
                            this.tags.push({ data: tag, count: 1 }); // Add new tag with count 1
                        }
                    });
                }
            }
        });
 
 
        app.mount('#app');
    </script>
</body>
 
</html>

reader.js

export class SerialTransport {
    constructor() {
        this.port = null;
        this.reader = null;
    }
 
    getDeviceInfo() {
        if (!this.port) {
            console.error("Port is not initialized.");
            return null;
        }
        const { usbVendorId, usbProductId } = this.port.getInfo();
        const deviceName = `Vendor ID: ${usbVendorId}, Product ID: ${usbProductId}`;
        return deviceName;
    }
 
    async open(baudRate = 57600, bufferSize = 1024) {
        // You can set filter if need it
        // { usbVendorId: 4292, usbProductId: 60000 } just for HW-VX6336 reader
        // this.port = await navigator.serial.requestPort({
        //     filters: [{ usbVendorId: 4292, usbProductId: 60000 }]
        // });
        this.port = await navigator.serial.requestPort();
        await this.port.open({ baudRate, bufferSize });
        this.reader = this.port.readable?.getReader({ mode: "byob" });
        console.log("Port opened successfully.");
    }
 
    async close() {
        if (!this.port) {
            return;
        }
 
        if (this.reader) {
            await this.reader.releaseLock();
        }
 
        await this.port?.close();
        console.log("Port closed successfully.");
    }
 
    async clear() {
        if (!this.reader) {
            console.error("Reader is not initialized.");
            return;
        }
 
        try {
            const controller = new AbortController();
            const signal = controller.signal;
 
            // Set a timeout to abort the read operation after 100ms (adjust as needed)
            const timeoutId = setTimeout(() => {
                controller.abort(); // Abort the read operation
                console.log("Clear operation timed out, moving on...");
            }, 100);
 
            // Attempt to read from the port
            const { value: bytes } = await this.reader.read(new Uint8Array(1024), { signal });
 
            // Clear the timeout if read is successful or times out
            clearTimeout(timeoutId);
 
            if (!bytes || bytes.length === 0) {
                console.log("No data in buffer, clearing completed.");
                return;
            }
 
            // Log the cleared data if any
            console.log("Buffer cleared:", bytes);
        } catch (error) {
            if (error.name === 'AbortError') {
                // This means the read operation was aborted due to timeout
                console.log("Clear operation aborted due to timeout, moving on...");
            } else {
                // Log other errors
                console.error("Error during buffer clear:", error);
            }
        }
    }
 
    async read(size) {
        if (!this.reader) {
            console.error("Reader is not initialized.");
            return null;
        }
 
        try {
            const controller = new AbortController();
            const signal = controller.signal;
 
            // Set a timeout to abort the read operation
            const timeoutId = setTimeout(() => {
                controller.abort();
                console.error("Read operation timed out.");
            }, 1000);
 
            const { value: bytes } = await this.reader.read(new Uint8Array(size), { signal });
            clearTimeout(timeoutId); // Clear timeout if read is successful
            return bytes;
        } catch (error) {
            if (error.name === 'AbortError') {
                console.error("Read operation was aborted due to timeout.");
            } else {
                console.error("Error during read:", error);
            }
            return null;
        }
    }
 
    async write(data) {
        try {
            const writer = this.port?.writable?.getWriter();
            if (!writer) {
                console.error("Unable to write: Port not writable.");
                return;
            }
 
            await writer.write(data);
            await writer.close();
        } catch (error) {
            console.error("Error during write:", error);
        }
    }
}
 
export class Reader {
    constructor(transport) {
        this.transport = transport;
    }
 
    // Please refer to protocol documentation, how data bytes are read and parsed.
    // This is using the HW-VX series reader.
    async inventoryAnswerMode() {
        try {
            // Send Inventory, please check the documentation
            const inventoryCommand = new Uint8Array([0x04, 0xFF, 0x01, 0x1B, 0xB4]);
            await this.transport.write(inventoryCommand);
 
            // Read first byte to get the length of next bytes
            const firstByte = await this.transport.read(1);
            const byteLength = firstByte?.[0];
 
            if (!byteLength) {
                console.error("Invalid first byte length.");
                return null;
            }
            if (byteLength == 0) {
                return null;
            }
 
            // Read additional bytes as indicated by the first byte
            const dataBytes = await this.transport.read(byteLength);
 
            if (!dataBytes) {
                console.error("Failed to read additional bytes.");
                return null;
            }
 
            // Combine first byte and the additional bytes
            const frame = new Uint8Array(firstByte.byteLength + dataBytes.byteLength);
            frame.set(firstByte);
            frame.set(dataBytes, firstByte.byteLength);
 
            // Parse to Response class
            const response = new Response(frame);
            if (!response.validateChecksum()) {
                console.log("Checksum is not valid!");
                console.log("Clearing buffer...");
                await this.transport.clear();
                console.log("Buffer cleared. Proceeding...");
                return [];
            }
 
            const data = response.data;
 
            if (data.length === 0) {
                return [];
            }
 
            const tags = [];
            const tagCount = data[0]; // The first byte indicates the number of tags
 
            console.log("Tag count: " + tagCount);
 
            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 (error) {
            console.error("Error in inventoryAnswerMode:", error);
            return null;
        }
    }
}
 
class Response {
    constructor(responseBytes) {
        if (!(responseBytes instanceof Uint8Array)) {
            throw new TypeError("Expected responseBytes to be an instance of Uint8Array.");
        }
 
        this.responseBytes = responseBytes;
        this.length = responseBytes[0];
        this.readerAddress = responseBytes[1];
        this.command = responseBytes[2];
        this.status = responseBytes[3];
        this.data = responseBytes.slice(4, -2);
        this.checksum = responseBytes.slice(-2);
    }
 
    getChecksumValue() {
        return (this.checksum[1] << 8) | this.checksum[0];
    }
 
    validateChecksum() {
        const calculatedChecksum = this.calculateChecksum(this.responseBytes.slice(0, -2));
        return this.getChecksumValue() === calculatedChecksum;
    }
 
    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

Telah di uji pada browser:

  • Chrome: Version 131.0.6778.85 (Official Build) (64-bit)
  • Brave: Version 1.73.91 Chromium: 131.0.6778.85 (Official Build) (64-bit)