@danidoble/webserial-hopper
v1.0.0
Published
A strongly-typed, event-driven USB hopper driver for the Web Serial API, built on top of webserial-core.
Readme
@danidoble/webserial-hopper
A strongly-typed, event-driven coin hopper driver built on top of webserial-core.
Supports up to 4 independent coin hoppers, a coin validator, balance tracking, and change dispensing — all through a clean event API. The binary serial protocol, handshake, frame parsing, auto-reconnect, and multi-frame splitting are handled automatically.
Not tied to a single transport: swap in the WebUSB, Web Bluetooth, or WebSocket provider from webserial-core, or implement your own SerialProvider for any platform.
Requirements
webserial-core^2.1.0(peer dependency)- A compatible transport (see Providers):
- Web Serial API — Chrome / Edge 89+ (default, no extra setup)
- WebUSB — Chrome / Edge (via
WebUsbProvider) - Web Bluetooth — Chrome / Edge (via
createBluetoothProvider, Nordic UART Service) - WebSocket — any environment (via
createWebSocketProvider+ a bridge server) - Custom — any platform via your own
SerialProviderimplementation
Installation
# npm
npm install @danidoble/webserial-hopper webserial-core
# pnpm
pnpm add @danidoble/webserial-hopper webserial-core
# yarn
yarn add @danidoble/webserial-hopper webserial-core
# bun
bun add @danidoble/webserial-hopper webserial-core
webserial-coreis a peer dependency — it must be installed alongside this package.
Quick start
import { Hopper } from '@danidoble/webserial-hopper';
const hopper = new Hopper({ filters: [{ usbVendorId: 0x2341 }] });
// ── Configure hoppers (optional — these are the defaults) ──────────────
hopper
.setHopperName({ hopper: 1, name: '10 Pesos' })
.setHopperCurrency({ hopper: 1, currency: 10 })
.setMaxCapacity({ hopper: 1, capacity: 1000 });
// ── Listen for events ──────────────────────────────────────────────────
hopper.on('serial:connecting', () => console.log('Opening port…'));
hopper.on('serial:connected', () => console.log('Port open, running handshake…'));
hopper.on('serial:disconnected', () => console.log('Disconnected'));
hopper.on('hopper:levels', (levels) => console.log('All levels:', levels));
hopper.on('hopper:updated', (level) => console.log(`Hopper ${level.id} → ${level.amount}`));
hopper.on('hopper:coin:inserted', ({ coinValue }) => console.log('Coin inserted, value:', coinValue));
hopper.on('hopper:balance:updated', ({ balance }) => console.log('Balance:', balance));
hopper.on('hopper:dispense-change', ({ amount }) => console.log('Change dispensed:', amount));
hopper.on('hopper:message', (msg) => console.log(`[${msg.no_code}] ${msg.name}`));
// ── Open the port (requires a user gesture) ────────────────────────────
await hopper.connect();
// ── Read all hopper levels ─────────────────────────────────────────────
await hopper.sendRequestStatus();
// ── Read / write a single hopper ──────────────────────────────────────
await hopper.sendReadHopper({ hopper: 1 });
await hopper.sendWriteHopper({ hopper: 1, quantity: 500 });
// ── Dispense from a specific hopper ───────────────────────────────────
await hopper.sendDispenseHopper({ hopper: 1 });
// ── Dispense exact change (sum across hoppers) ─────────────────────────
await hopper.sendDispenseChange({ change: 15 }); // dispenses 15 units
// ── Balance management ─────────────────────────────────────────────────
await hopper.sendReadBalance();
await hopper.sendClearBalance();
// ── Validator (coin acceptor) ──────────────────────────────────────────
await hopper.sendEnableValidator();
await hopper.sendDisableValidator();Serial settings
The constructor pre-configures the following defaults — no extra setup needed:
| Setting | Value | | ------------------ | ------------------------ | | Baud rate | 9 600 | | Data bits | 8 | | Stop bits | 1 | | Parity | none | | Flow control | none | | Buffer size | 255 B | | Parser | interByteTimeout (40 ms) | | Command timeout | 3 000 ms | | Auto-reconnect | ✓ | | Reconnect interval | 1 500 ms | | Handshake timeout | 2 000 ms |
Providers
By default the library uses the browser's native Web Serial API (navigator.serial). You can replace this with any of the built-in providers from webserial-core, or write your own.
Web Serial API (default)
No setup required — works out of the box in Chrome / Edge 89+.
import { Hopper } from '@danidoble/webserial-hopper';
const hopper = new Hopper({ filters: [{ usbVendorId: 0x2341 }] });
await hopper.connect();WebUSB (WebUsbProvider)
Use the WebUSB API as the transport. Useful for devices or platforms where the native Web Serial API is unavailable, or when targeting CP210x / vendor-specific USB chips.
import { Hopper, WebUsbProvider } from '@danidoble/webserial-hopper';
const hopper = new Hopper({
filters: [{ usbVendorId: 0x2341 }],
provider: new WebUsbProvider()
});
await hopper.connect();Web Bluetooth (createBluetoothProvider)
Communicate over Bluetooth Low Energy using the Nordic UART Service (NUS). The device must expose NUS characteristics.
import { Hopper, createBluetoothProvider } from '@danidoble/webserial-hopper';
const hopper = new Hopper({
provider: createBluetoothProvider()
});
await hopper.connect(); // shows the browser Bluetooth pickerWebSocket (createWebSocketProvider)
Route serial communication through a WebSocket bridge server — ideal for Node.js environments or remote devices. A reference bridge implementation is available in the webserial-core demos.
import { Hopper, createWebSocketProvider } from '@danidoble/webserial-hopper';
const hopper = new Hopper({
filters: [{ usbVendorId: 0x2341 }],
provider: createWebSocketProvider('ws://localhost:8080')
});
await hopper.connect();Global provider (AbstractSerialDevice.setProvider)
Set a provider once for all device instances instead of per-instance. Import AbstractSerialDevice directly from webserial-core:
import { AbstractSerialDevice, WebUsbProvider } from 'webserial-core';
import { Hopper } from '@danidoble/webserial-hopper';
AbstractSerialDevice.setProvider(new WebUsbProvider());
const hopper = new Hopper({ filters: [{ usbVendorId: 0x2341 }] });
await hopper.connect();Custom provider
Implement the SerialProvider interface to target any platform:
import type { SerialProvider, SerialPortFilter } from '@danidoble/webserial-hopper';
import { Hopper } from '@danidoble/webserial-hopper';
const myProvider: SerialProvider = {
async requestPort(options?: { filters?: SerialPortFilter[] }): Promise<SerialPort> {
// return a SerialPort-compatible object
},
async getPorts(): Promise<SerialPort[]> {
// return previously authorised ports
}
};
const hopper = new Hopper({
filters: [{ usbVendorId: 0x2341 }],
provider: myProvider
});API
new Hopper(options?)
| Option | Type | Default | Description |
| ----------------- | --------------------- | ------- | ------------------------------------------------------------------ |
| filters | SerialPortFilter[] | [] | USB vendor/product filters for port matching. |
| provider | SerialProvider | — | Per-instance provider. Overrides the global static provider. |
| polyfillOptions | SerialDeviceOptions | — | Extra options forwarded to the provider (e.g. baud rate override). |
hopper.connect()
Opens the serial port and runs the binary handshake. Shows a browser port-picker dialog on first connection; subsequent calls reuse the last authorised port.
await hopper.connect();hopper.disconnect()
Gracefully closes the port and stops auto-reconnect.
await hopper.disconnect();hopper.isConnected()
Returns true when the port is open and the handshake has completed.
Commands
hopper.sendConnect()
Sends the connection handshake frame manually. Useful if you need to re-trigger the handshake.
hopper.sendRequestStatus()
Requests the live coin level of all 4 hoppers at once. Triggers a hopper:levels event.
await hopper.sendRequestStatus();hopper.sendReadHopper(options?)
Reads the level of a single hopper. Triggers hopper:updated.
| Option | Type | Default | Description |
| -------- | -------------- | ------- | ------------------------ |
| hopper | HopperNumber | 1 | Hopper to read (1 – 4). |
await hopper.sendReadHopper({ hopper: 2 });hopper.sendWriteHopper(options?)
Writes (overwrites) the coin count of a single hopper. Triggers hopper:updated.
| Option | Type | Default | Description |
| ---------- | -------------- | ------- | ---------------------------------------- |
| hopper | HopperNumber | 1 | Hopper to write (1 – 4). |
| quantity | number | 0 | New coin count (−32 768 – 32 767, integer). |
await hopper.sendWriteHopper({ hopper: 1, quantity: 500 });hopper.sendDispenseHopper(options?)
Dispenses one coin from the specified hopper. Triggers hopper:updated.
| Option | Type | Default | Description |
| -------- | -------------- | ------- | ------------------------- |
| hopper | HopperNumber | 1 | Hopper to dispense from. |
await hopper.sendDispenseHopper({ hopper: 3 });hopper.sendDispenseChange(options?)
Dispenses the exact change amount using the available hoppers. Triggers hopper:dispense-change.
| Option | Type | Default | Description |
| -------- | -------- | ------- | ------------------------------------ |
| change | number | 0 | Amount to dispense (0 – 32 767, integer). |
await hopper.sendDispenseChange({ change: 15 });hopper.sendReadBalance()
Reads the accumulated balance (total inserted value). Triggers hopper:balance:updated.
await hopper.sendReadBalance();hopper.sendClearBalance()
Clears the accumulated balance. Triggers hopper:balance:updated.
await hopper.sendClearBalance();hopper.sendEnableValidator() / hopper.sendDisableValidator()
Enables or disables the coin validator (acceptor). Shorthand for sendConfigValidator.
await hopper.sendEnableValidator();
await hopper.sendDisableValidator();hopper.sendConfigValidator(options?)
Low-level validator toggle.
| Option | Type | Default | Description |
| -------- | --------- | ------- | ----------------------------- |
| enable | boolean | false | true to enable the validator. |
await hopper.sendConfigValidator({ enable: true });hopper.sendChange1x1(options?)
Sends a 1-for-1 change command (platform-specific).
| Option | Type | Default | Description |
| -------- | -------------- | ------- | ----------------- |
| hopper | HopperNumber | 1 | Target hopper. |
hopper.sendCustomCode(options?)
Sends a raw byte array as a command frame. Use for vendor-specific extensions.
| Option | Type | Default | Description |
| ------ | ---------- | ------- | ------------------------------------- |
| code | number[] | [] | Bytes to send (each 0 – 255, max 12). |
await hopper.sendCustomCode({ code: [0x0a, 0xff, 0x01] });Configuration (chainable)
These methods update the in-memory hopper metadata and return this for chaining. They do not send any serial command.
hopper.setMaxCapacity(options?)
| Option | Type | Default | Description |
| ---------- | -------------- | -------- | ------------------------------------ |
| hopper | HopperNumber | 1 | Hopper to configure (1 – 4). |
| capacity | number | 1000 | Maximum coin capacity. |
hopper.setHopperName(options?)
| Option | Type | Default | Description |
| -------- | -------------- | ------- | ---------------------------- |
| hopper | HopperNumber | 1 | Hopper to configure (1 – 4). |
| name | string | '' | Human-readable label. |
hopper.setHopperKey(options?)
| Option | Type | Default | Description |
| -------- | -------------- | ------- | ------------------------------------------ |
| hopper | HopperNumber | 1 | Hopper to configure (1 – 4). |
| key | string | '' | Machine-readable identifier (e.g. "ten_pesos"). |
hopper.setHopperCurrency(options?)
| Option | Type | Default | Description |
| ---------- | -------------- | ------- | -------------------------------------- |
| hopper | HopperNumber | 1 | Hopper to configure (1 – 4). |
| currency | number | 1 | Coin denomination (positive number). |
// Chainable example
hopper
.setHopperName({ hopper: 1, name: '10 Pesos' })
.setHopperCurrency({ hopper: 1, currency: 10 })
.setMaxCapacity({ hopper: 1, capacity: 800 });Getters
| Getter | Type | Description |
| ------------------------ | ----------------------- | ---------------------------------------------------- |
| hopper.balance | number | Last known accumulated balance from the validator. |
| hopper.currentHopper | HopperNumber \| null | The hopper targeted by the most recent command. |
| hopper.levels | HopperLevel[] | Array of 4 HopperLevel objects (one per hopper). |
HopperLevel shape:
interface HopperLevel {
id: number; // 1 – 4
currency: number; // denomination value (e.g. 10)
key: string; // machine key (e.g. "Hopper 1")
name: string; // display label (e.g. "10 Pesos")
amount: number; // current coin count
capacity: number; // maximum capacity
}Events
Core events (from webserial-core)
| Event | Payload | Description |
| ------------------------ | --------------------------------- | ------------------------------------------------- |
| serial:connecting | instance | Port is being opened. |
| serial:connected | instance | Port opened successfully. |
| serial:disconnected | instance | Port closed or device unplugged. |
| serial:reconnecting | instance | Auto-reconnect attempt in progress. |
| serial:data | data: Uint8Array, instance | Raw binary frame received from the device. |
| serial:sent | data: Uint8Array, instance | Raw bytes written to the port. |
| serial:error | error: Error, instance | An error occurred during communication. |
| serial:need-permission | instance | No authorised port found; user must grant access. |
| serial:timeout | command: Uint8Array, instance | A queued command timed out. |
Hopper events
| Event | Payload | Description |
| -------------------------- | ---------------------------- | ------------------------------------------------------- |
| hopper:test | Uint8Array | Handshake / test frame received. |
| hopper:levels | HopperLevel[] | All 4 hopper levels updated (from sendRequestStatus). |
| hopper:updated | HopperLevel | Single hopper level updated (read / write / dispense). |
| hopper:dispense-change | { amount: number } | Change dispensed; amount reflects what was paid out. |
| hopper:balance:updated | { balance: number } | Accumulated balance changed (read or cleared). |
| hopper:coin:inserted | { coinValue: number } | Coin(s) inserted by the customer (debounced 500 ms; coinValue is the pulse count). |
| hopper:validator:message | HopperMessage | Message from the coin validator module. |
| hopper:unknown | HopperMessage | Unrecognised but valid frame received. |
| hopper:message | HopperMessage | Emitted for every device response. |
HopperMessage
interface HopperMessage {
code: Uint8Array; // raw frame bytes
name: string | null; // protocol name (e.g. "STATUS")
description: string | null;
request: string | null;
no_code: number; // see table below
error: boolean;
data: unknown;
hopper?: HopperNumber | null;
}no_code reference
| no_code | Name | Trigger |
| --------- | ------------------ | ------------------------------------------------- |
| 0 | TEST | Handshake / test response. |
| 1 | STATUS | All hopper levels (sendRequestStatus). |
| 2 | READ_HOPPER | Single hopper read (sendReadHopper). |
| 3 | WRITE_HOPPER | Single hopper write (sendWriteHopper). |
| 4 | DISPENSE_HOPPER | Dispense from hopper (sendDispenseHopper). |
| 5 | DISPENSE_CHANGE | Change dispensed (sendDispenseChange). |
| 6 | READ_BALANCE | Balance read (sendReadBalance). |
| 7 | CLEAR_BALANCE | Balance cleared (sendClearBalance). |
| 8 | VALIDATOR_MESSAGE| Message from coin validator. |
| 9 | INSERTED_COIN | Coin inserted by customer. |
| 400 | SYNTAX_ERROR | Device reported a syntax error (0xFF 0xFF 0xFF). |
| 401 | LOW_LEVEL | Low coin level in hopper (0xFF 0xAA 0xAA). |
| 402 | TIMEOUT_DISPENSE | Dispense timed out (0xFF 0xBB 0xBB). |
| 999 | UNKNOWN | Unrecognised frame. |
TypeScript
All events and method signatures are fully typed. The package ships with .d.mts / .d.cts declaration files — no extra @types package required.
Commonly used types and all built-in providers are re-exported so you do not need to import directly from webserial-core:
import {
Hopper,
WebUsbProvider,
createBluetoothProvider,
createWebSocketProvider,
} from '@danidoble/webserial-hopper';
import type {
HopperOptions,
HopperLevel,
HopperMessage,
HopperNumber,
MaxCapacityOptions,
HopperNameOptions,
HopperKeyOptions,
HopperCurrencyOptions,
ReadHopperOptions,
WriteHopperOptions,
DispenseHopperOptions,
DispenseChangeOptions,
ConfigValidatorOptions,
Change1x1Options,
CustomCodeOptions,
SerialPortFilter,
SerialDeviceOptions,
SerialEventMap,
SerialProvider,
SerialPolyfillOptions,
} from '@danidoble/webserial-hopper';