Macotron

macotron.hid

Talk to keyboards, mice, macro pads, status lights, and other HID devices with raw reports, the way hidapi and hidapitester do.

// Turn a blink(1) status light red: report 1, fade command "c", RGB, fade time.
const dev = macotron.hid.open("27b8/1ed");
if (dev) {
  macotron.hid.sendFeature(dev.id, [1, 0x63, 255, 0, 0, 0, 10], { length: 9 });
  macotron.hid.close(dev.id);
}

Finding devices

A device is described by HIDDeviceInfo:

FieldTypeMeaning
namestringProduct name, or "HID device" when it reports none.
vendorstringManufacturer name, or "".
vendorID, productIDnumberUSB vendor and product IDs.
usagePage, usagenumberThe primary HID usage, for example page 1, usage 6 for a keyboard.
serialstringSerial number, or "".
pathstringThe IORegistry path. It tells apart two interfaces of the same device.
maxInput, maxOutput, maxFeaturenumberLargest report of each kind in bytes. 64 when the device does not say.
batterynumber?Charge from 0 to 100, only for the few devices that publish one. Bluetooth mice and keyboards report charge through network.bluetooth() instead.

A HIDFilter narrows the match. Every field is optional, and a device must match all the fields you give:

FieldTypeMatches
vendorID, productIDnumberUSB IDs.
usagePage, usagenumberPrimary usage.
serialstringExact serial number.
pathstringExact IORegistry path.
vidpidstringHex IDs like "27b8/1ed". vendorID and productID override it.

Anywhere a filter goes, you can pass the vidpid string by itself: "27b8/1ed", "27b8:01ed", or just a vendor such as "05ac".

list(filter?: HIDFilter | string): HIDDeviceInfo[]

Every HID device that matches. With no filter, every HID device on the Mac, including the built-in keyboard and trackpad.

open(filter?: HIDFilter | string): (HIDDeviceInfo & { id: string }) | null

Opens the first matching device and returns its info plus an id for the other calls. Returns null when nothing matches or macOS will not open the device. Each call opens a new session with a new id.

macOS opens keyboards only for apps that have Input Monitoring permission. Many devices expose several interfaces under one vendor and product ID; add usagePage or path to the filter to pick the right one.

close(id: string): void

Closes the session. Pending readInput calls reject. Macotron closes every session when plugins reload.

Sending reports

data is an array of bytes (0–255) or a string such as "1,99,0,255". In a string, bytes are separated by commas, spaces, or colons, and a byte is hex when it starts with 0x or holds a letter ("ff"), and decimal otherwise.

The first byte is the report ID, as in hidapi. Use 0 when the device does not number its reports; Macotron does not send that leading 0. A non-zero report ID stays in the data that is sent.

sendOutput(id: string, data: number[] | string, opts?: { length?: number }): { ok, written?, error? }

Sends an output report. length pads the data with zeros up to that many bytes, counting the report ID. On success, written is the number of bytes sent. On failure, error is "not open", "bad data", "empty report", or an IOKit error code such as "0xe00002ed".

sendFeature(id: string, data: number[] | string, opts?: { length?: number }): { ok, written?, error? }

Sends a feature report. It works the same as sendOutput.

Reading reports

readInput(id: string, opts?: { timeout?: number }): Promise<{ id, reportId, data } | null>

Waits for the next input report from the device, like hid_read. This is how most devices answer a command. timeout is milliseconds and defaults to 1000. Resolves null when nothing arrives in time, and rejects when the session is not open.

Reports queue from the moment open returns, so a reply that arrives before you call readInput is not lost. The queue holds the newest 64 reports.

const dev = macotron.hid.open({ vendorID: 0x1234, productID: 0x5678, usagePage: 0xff00 });
if (dev) {
  macotron.hid.sendOutput(dev.id, [0, 0x01], { length: 65 });
  const reply = await macotron.hid.readInput(dev.id, { timeout: 500 });
  if (reply) console.log(reply.data);
}

readInputReport(id: string, opts?: { reportId?: number; length?: number }): number[] | null

Asks for an input report with a control transfer (GetReport). Most devices never answer it; use readInput unless you know the device does. Returns the bytes with the report ID first, or null on failure. length defaults to the device's largest input report plus one.

readFeature(id: string, reportId: number, opts?: { length?: number }): number[] | null

Reads a feature report. Returns the bytes with the report ID first, or null on failure. length defaults to the device's largest feature report plus one.

reportDescriptor(id: string): number[] | null

The device's raw HID report descriptor, which lists its reports and their sizes. Returns null when the session is not open or the device does not publish one.

Streaming input

listen(id: string): { ok: boolean; error?: string }

Sends every input report from the device as a hid:input event. While a session listens, reports go to the event instead of the readInput queue, and the queue is emptied. A readInput call that is already waiting still gets the next report. Returns { ok: false, error: "not open" } for an unknown id.

unlisten(id: string): void

Stops the events. Reports queue for readInput again.

Events

hid:input

Fires for each input report from a session that is listening. The payload is { id, reportId, data }, where id is the session and data is an array of bytes.

const pad = macotron.hid.open({ vidpid: "1d50/615e" });
if (pad) macotron.hid.listen(pad.id);

macotron.on("hid:input", ({ id, data }) => {
  if (pad && id === pad.id) console.log("keys", data);
});

See also