@danidoble/webserial-jofemar
v1.0.0
Published
A strongly-typed, event-driven USB jofemar driver for the Web Serial API, built on top of webserial-core.
Readme
@danidoble/webserial-jofemar
A strongly-typed, event-driven driver for Jofemar vending machines (ES-Plus / Ice-Plus) built on top of webserial-core.
Handles the serial connection, binary handshake, auto-reconnect, and message routing — so you only deal with clean, typed events.
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-jofemar webserial-core
# pnpm
pnpm add @danidoble/webserial-jofemar webserial-core
# yarn
yarn add @danidoble/webserial-jofemar webserial-core
# bun
bun add @danidoble/webserial-jofemar webserial-core
webserial-coreis a peer dependency — it must be installed alongside this package.
Quick start
import { Jofemar } from '@danidoble/webserial-jofemar';
import type { SerialPortFilter } from '@danidoble/webserial-jofemar';
const filters: SerialPortFilter[] = [{ usbVendorId: 0x0403 }]; // FTDI chip
const machine = new Jofemar({ channel: 1, type: 'esplus', filters });
machine.on('serial:connecting', () => console.log('Opening port…'));
machine.on('serial:connected', () => console.log('Port open'));
machine.on('serial:disconnected', () => console.log('Disconnected'));
machine.on('jofemar:connected', ({ channel }) => console.log(`Jofemar on channel ${channel} ready`));
machine.on('jofemar:machine-status', (msg) => console.log(`[${msg.no_code}] ${msg.name}`));
machine.on('jofemar:warning', ({ type }) => console.warn('Warning:', type));
machine.on('jofemar:error', ({ type }) => console.error('Error:', type));
machine.on('jofemar:message', (msg) => console.log(`[${msg.no_code}] ${msg.name}`));
// Opens a port picker dialog (requires a user gesture)
await machine.connect();
// Dispense selection 5 (returns DispenseResult)
const result = await machine.sendDispense({ selection: 5 });
console.log(result.status); // true | false
console.log(result.error); // null | 'not-dispensed' | 'elevator-locked' | 'no-response' | 'send-failed'
// Check machine status
await machine.sendStatus();
// Collect the dispensed product
await machine.sendCollect();
// Reset errors
await machine.sendResetAll();
// Scan channels 1–80 for presence
machine.startChannelVerification = 1;
machine.endChannelVerification = 80;
const channels = await machine.sendAssignChannels();
console.log(channels); // [{ selection: 1, active: true }, ...]Serial settings
The constructor pre-configures the following defaults — no extra setup needed:
| Setting | Value | | -------------------- | ------------------------ | | Baud rate | 9600 | | Data bits | 8 | | Stop bits | 1 | | Parity | none | | Flow control | none | | Buffer size | 255 B | | Parser | interByteTimeout (40 ms) | | Command timeout | 4 000 ms | | Auto-reconnect | ✓ | | Reconnect interval | 1 500 ms | | Handshake timeout | 3 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 { Jofemar } from '@danidoble/webserial-jofemar';
const machine = new Jofemar({ filters: [{ usbVendorId: 0x0403 }] });
await machine.connect();WebUSB (WebUsbProvider)
import { Jofemar, WebUsbProvider } from '@danidoble/webserial-jofemar';
const machine = new Jofemar({
filters: [{ usbVendorId: 0x0403 }],
provider: new WebUsbProvider(),
});
await machine.connect();Web Bluetooth (createBluetoothProvider)
import { Jofemar, createBluetoothProvider } from '@danidoble/webserial-jofemar';
const machine = new Jofemar({ provider: createBluetoothProvider() });
await machine.connect();WebSocket (createWebSocketProvider)
import { Jofemar, createWebSocketProvider } from '@danidoble/webserial-jofemar';
const machine = new Jofemar({
filters: [{ usbVendorId: 0x0403 }],
provider: createWebSocketProvider('ws://localhost:8080'),
});
await machine.connect();Global provider (AbstractSerialDevice.setProvider)
import { AbstractSerialDevice, WebUsbProvider } from 'webserial-core';
import { Jofemar } from '@danidoble/webserial-jofemar';
AbstractSerialDevice.setProvider(new WebUsbProvider());
const machine = new Jofemar({ filters: [{ usbVendorId: 0x0403 }] });
await machine.connect();Custom provider
import type { SerialProvider, SerialPortFilter } from '@danidoble/webserial-jofemar';
import { Jofemar } from '@danidoble/webserial-jofemar';
const myProvider: SerialProvider = {
async requestPort(options?: { filters?: SerialPortFilter[] }): Promise<SerialPort> {
// return a SerialPort-compatible object
},
async getPorts(): Promise<SerialPort[]> {
// return previously authorised ports
},
};
const machine = new Jofemar({ filters: [{ usbVendorId: 0x0403 }], provider: myProvider });API
new Jofemar(options?)
| Option | Type | Default | Description |
| ----------------- | --------------------- | ----------- | ------------------------------------------------------------------ |
| channel | number | 1 | Machine address used in the handshake and all outbound commands. |
| type | DeviceType | 'esplus' | Machine model. Affects temperature limits and reset duration. |
| supportCart | boolean | true | Whether the machine has a cart (affects dispense command byte). |
| 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 underlying core device. |
Getters and setters
| Name | Type | R/W | Notes |
| --------------------------- | ---------------------- | --- | ----------------------------------------- |
| isDispensing | boolean | R | true while a dispense is in flight. |
| isDoorOpen | boolean | R | Updated from door-event frames. |
| deviceType | DeviceType | R/W | 'esplus' | 'iceplus' |
| supportCart | boolean | R/W | — |
| listenOnChannel | number | R/W | Valid range: 1–31. Throws on invalid. |
| startChannelVerification | number \| string | W | Valid range: 1–126. Throws on invalid. |
| endChannelVerification | number \| string | W | Valid range: 1–126. Throws on invalid. |
Connection
jofemar.connect()
Opens the serial port and performs the handshake. Shows a browser port-picker dialog on first use.
jofemar.disconnect()
Gracefully closes the port and stops auto-reconnect.
Dispense
jofemar.sendDispense(options?)
Dispenses the product at the given selection. Automatically retries on elevator-locked or no-response.
| Option | Type | Default | Description |
| ----------- | ------------------ | ------- | -------------------------------------------------------- |
| selection | number \| string | 1 | Channel selection (1–130). |
| cart | boolean | false | Use the cart-dispense command byte instead of standard. |
Returns Promise<DispenseResult>.
const result = await machine.sendDispense({ selection: 3, cart: false });
// { status: true, error: null }
// { status: false, error: 'not-dispensed' | 'elevator-locked' | 'no-response' | 'send-failed' }jofemar.sendProductRemoved()
Signals that the product from the elevator has been physically removed, immediately ending the withdrawal wait.
Status and collect
| Method | Returns | Description |
| --------------------- | -------------- | -------------------------------------------------- |
| sendStatus() | Promise<void> | Request machine status. Triggers jofemar:machine-status. |
| sendCollect() | Promise<void> | Signal that the dispensed product has been collected. |
| sendEndDispense() | Promise<void> | End a cart-mode dispense cycle. |
Reset
| Method | Returns | Description |
| ------------------------------- | -------------- | -------------------------------------------------------------------- |
| sendResetSoldOut() | Promise<void> | Reset sold-out errors. |
| sendResetWaitingProduct() | Promise<void> | Reset waiting-product errors. |
| sendResetMachine() | Promise<void> | Full machine reset. Emits jofemar:reset-errors. 25 s (ES-Plus) / 40 s (Ice-Plus). |
| sendResetAll() | Promise<void> | Runs sendResetWaitingProduct → sendResetSoldOut → sendResetMachine in sequence. |
Lights
| Method | Returns | Description |
| ------------------- | -------------- | -------------------- |
| sendLightsOn() | Promise<void> | Turn interior lights on. |
| sendLightsOff() | Promise<void> | Turn interior lights off. |
Program (write settings)
| Method | Parameters | Description |
| -------------------------------------------------------- | --------------------------------------------- | ---------------------------------------- |
| sendProgram(param1, param2) | param1: number, param2: number | Send a raw program command. |
| sendProgramDisplayLanguage({ language? }) | 'spanish' \| 'english' \| 'french' | Set display language. |
| sendProgramBeeper({ enable? }) | enable: boolean (default true) | Enable/disable beeper. |
| sendProgramDisableWorkingTemperature() | — | Disable working temperature control (ES-Plus only). |
| sendProgramDisableThermometer() | — | Alias for disable working temperature. |
| sendProgramWorkingTemperature({ degrees? }) | degrees: number (multiple of 0.5) | Set working temperature. |
| sendProgramIsolationTray({ tray? }) | tray: number (0–12) | Set isolation tray number (0 = none). |
| sendProgramStandbyAfterCollect({ seconds? }) | seconds: number (15–120) | Time to standby after product collection. |
| sendProgramStandbyWithoutCollect({ minutes? }) | minutes: number | Time to standby when product is not collected. |
| sendProgramElevatorSpeed({ speed? }) | 'high' \| 'low' | Set elevator speed. |
| sendProgramTemperatureExpiration({ enable? }) | enable: boolean (default false) | Enable/disable temperature expiration. |
| sendProgramEnableTemperatureExpiration() | — | Enable temperature expiration. |
| sendProgramDisableTemperatureExpiration() | — | Disable temperature expiration. |
| sendProgramMachineAddress({ address? }) | address: number (default 1) | Assign machine channel address. |
| sendProgramTemperatureBeforeExpiration({ degrees? }) | degrees: number | Set temperature threshold before expiration. |
| sendProgramTimeBeforeExpiration({ minutes? }) | minutes: number (default 1) | Time before expiration by temperature. |
| sendProgramTemperatureScale({ scale? }) | 'celsius' \| 'fahrenheit' | Set temperature display scale. |
| sendProgramVoltageEngine({ selection?, voltage? }) | selection: number, voltage: number | Set engine voltage for a channel. |
| sendProgramPushOverProducts({ selection?, enable? }) | selection: number, enable: boolean | Enable/disable push-over for a channel. |
| sendProgramChannelRunningAfterDispense({ selection?, seconds? }) | selection: number, seconds: number | Set channel run time after dispense. |
| sendProgramClock({ date? }) | date: Date (default new Date()) | Synchronise the machine clock. |
Get / Check (read settings)
All methods return Promise<void> and deliver their response via the corresponding jofemar:check-* event.
| Method | Response event |
| --------------------------------------------- | ------------------------------------------ |
| sendCheckData(type, aux?) | depends on type |
| sendGetDisplayLanguage() | jofemar:check-language |
| sendGetBeeper() | jofemar:check-beeper |
| sendGetWorkingTemperature() | jofemar:temperature-working |
| sendGetIsolationTray() | jofemar:check-isolation-tray |
| sendGetProgramVersion() | jofemar:program-version |
| sendGetFaults() | jofemar:machine-faults |
| sendGetMachineId() | jofemar:check-machine-id |
| sendGetCurrentTemperature() | jofemar:temperature-current |
| sendGetStandbyAfterCollect() | jofemar:check-standby-after-collect |
| sendGetStandbyWithoutCollect() | jofemar:check-standby-without-collect |
| sendGetElevatorSpeed() | jofemar:check-elevator-speed |
| sendGetTemperatureExpiration() | jofemar:check-expiration-by-temperature |
| sendGetTemperatureBeforeExpiration() | jofemar:check-temperature-before-expiration |
| sendGetTimeBeforeExpiration() | jofemar:check-expiration-after |
| sendGetTemperatureScale() | jofemar:check-temperature-scale |
| sendGetClockRegisters() | jofemar:clock-registers |
| sendGetMachineActivity() | jofemar:machine-activity |
| sendGetVoltageEngine({ selection? }) | jofemar:check-engine-voltage |
| sendGetChannelPresence({ selection? }) | jofemar:channel-status |
| sendGetPushOverProducts({ selection? }) | jofemar:check-push-over |
| sendGetChannelRunningAfterDispense({ selection? }) | jofemar:check-extractor-after-dispense |
Display
| Method | Parameters | Description |
| --------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------- |
| sendSetDisplayStandbyMessage({ message? }) | message: string (max 32 chars) | Set the idle/standby screen message. |
| sendSetDisplayMessageTemporarily({ message?, seconds? }) | message: string, seconds: number (default 1) | Show a message for the given number of seconds. |
| sendSetDisplayMessageUnlimited({ message? }) | message: string (max 32 chars) | Show a message indefinitely. |
Events config
| Method | Parameters | Description |
| ----------------------------------------------- | ------------------------------------------------------- | ------------------------------- |
| sendEventsConfig({ event?, enable? }) | event: number \| string \| null, enable: boolean | Configure an automatic event. |
| sendEventEnable({ event? }) | event: number \| string \| null | Enable an automatic event. |
| sendEventDisable({ event? }) | event: number \| string \| null | Disable an automatic event. |
Custom code
await machine.sendCustomCode({ code: [0x02, 0x30, 0x30, 0x81, 0x53, 0xff, 0xff] });Channel verification
Scans a range of selections for product presence. Configure the range with the startChannelVerification and endChannelVerification setters before calling.
machine.startChannelVerification = 1;
machine.endChannelVerification = 80;
machine.on('jofemar:channels-progress', ({ percentage }) => console.log(`${percentage}%`));
machine.on('jofemar:channels', ({ channels }) => console.log(channels));
const channels = await machine.sendAssignChannels();
// [{ selection: 1, active: true }, { selection: 2, active: false }, ...]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. |
Jofemar events
| Event | Payload | Description |
| ---------------------------------------- | ------------------------------------ | ------------------------------------------------------- |
| jofemar:connected | { channel: number } | Handshake succeeded; machine is ready. |
| jofemar:message | MessageSerial | Every parsed frame emits this. |
| jofemar:machine-status | MessageSerial | Status response (no_code 8–37). |
| jofemar:command-executed | MessageSerial | The last sent command was acknowledged by the machine. |
| jofemar:warning | JofemarWarningPayload | Non-fatal condition (empty channel, thermometer, etc.). |
| jofemar:error | JofemarErrorPayload | Hardware error (jam, malfunction, EEPROM, etc.). |
| jofemar:door-event | JofemarDoorEventPayload | Door opened or closed. |
| jofemar:keyboard-pressed | MessageSerialAdditional | A key was pressed on the machine's keypad. |
| jofemar:channel-status | JofemarChannelStatusPayload | Single-channel presence/availability response. |
| jofemar:program-version | MessageSerialAdditional | Response to sendGetProgramVersion(). |
| jofemar:machine-faults | MessageSerialAdditional | Response to sendGetFaults(). |
| jofemar:clock-registers | MessageSerialAdditional | Response to sendGetClockRegisters(). |
| jofemar:machine-activity | MessageSerialAdditional | Response to sendGetMachineActivity(). |
| jofemar:temperature-working | MessageSerialAdditional | Response to sendGetWorkingTemperature(). |
| jofemar:temperature-current | MessageSerialAdditional | Response to sendGetCurrentTemperature(). |
| jofemar:check-language | MessageSerialAdditional | Response to sendGetDisplayLanguage(). |
| jofemar:check-beeper | MessageSerialAdditional | Response to sendGetBeeper(). |
| jofemar:check-isolation-tray | MessageSerialAdditional | Response to sendGetIsolationTray(). |
| jofemar:check-engine-voltage | MessageSerialAdditional | Response to sendGetVoltageEngine(). |
| jofemar:check-push-over | MessageSerialAdditional | Response to sendGetPushOverProducts(). |
| jofemar:check-extractor-after-dispense | MessageSerialAdditional | Response to sendGetChannelRunningAfterDispense(). |
| jofemar:check-standby-after-collect | MessageSerialAdditional | Response to sendGetStandbyAfterCollect(). |
| jofemar:check-standby-without-collect | MessageSerialAdditional | Response to sendGetStandbyWithoutCollect(). |
| jofemar:check-elevator-speed | MessageSerialAdditional | Response to sendGetElevatorSpeed(). |
| jofemar:check-expiration-by-temperature| MessageSerialAdditional | Response to sendGetTemperatureExpiration(). |
| jofemar:check-temperature-before-expiration | MessageSerialAdditional | Response to sendGetTemperatureBeforeExpiration(). |
| jofemar:check-expiration-after | MessageSerialAdditional | Response to sendGetTimeBeforeExpiration(). |
| jofemar:check-temperature-scale | MessageSerialAdditional | Response to sendGetTemperatureScale(). |
| jofemar:check-machine-id | MessageSerialAdditional | Response to sendGetMachineId(). |
| jofemar:reset-errors | JofemarResetErrorsPayload | Emitted immediately when sendResetMachine() is called. |
| jofemar:channels | JofemarChannelsPayload | Final result of sendAssignChannels(). |
| jofemar:channels-progress | JofemarChannelsProgressPayload | Progress updates during sendAssignChannels(). |
| jofemar:dispensing-withdrawal | JofemarDispensingWithdrawalPayload | Emitted every second while waiting for elevator clearance. |
TypeScript
All events and method signatures are fully typed. The package ships with .d.mts / .d.cts declaration files — no extra @types package needed.
Providers and commonly used types are re-exported so you rarely need to import directly from webserial-core:
import { Jofemar, WebUsbProvider, createBluetoothProvider, createWebSocketProvider } from '@danidoble/webserial-jofemar';
import type {
DeviceType,
JofemarOptions,
DispenseOptions,
DispenseResult,
MessageSerial,
MessageSerialAdditional,
JofemarConnectedPayload,
JofemarWarningPayload,
JofemarErrorPayload,
JofemarDoorEventPayload,
JofemarChannelStatusPayload,
JofemarChannelsPayload,
JofemarChannelsProgressPayload,
JofemarDispensingWithdrawalPayload,
JofemarResetErrorsPayload,
SerialPortFilter,
SerialDeviceOptions,
SerialProvider,
} from '@danidoble/webserial-jofemar';