@ginden/blinkstick-v2
Version:
Improved Blickstick API for Node.js
697 lines • 27.9 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.BlinkStick = void 0;
const determine_report_id_1 = require("../utils/hid/determine-report-id");
const decimal_to_hex_1 = require("../utils/decimal-to-hex");
const get_info_block_1 = require("../utils/hid/get-info-block");
const set_info_block_1 = require("../utils/hid/set-info-block");
const clamp_1 = require("../utils/clamp");
const promises_1 = require("timers/promises");
const blinkstick_finalization_registry_1 = require("./blinkstick-finalization-registry");
const retry_n_times_1 = require("../utils/retry-n-times");
const interpret_parameters_1 = require("../utils/colors/interpret-parameters");
const device_descriptions_1 = require("../consts/device-descriptions");
const is_defined_1 = require("../utils/is-defined");
const animation_runner_1 = require("../animations/animation-runner");
const led_group_1 = require("../led/led-group");
const as_buffer_1 = require("../utils/as-buffer");
const led_1 = require("../led/led");
const tsafe_1 = require("tsafe");
const node_fs_1 = require("node:fs");
const node_util_1 = require("node:util");
const os = __importStar(require("node:os"));
const promises_2 = require("node:timers/promises");
const types_1 = require("../types");
const node_buffer_1 = require("node:buffer");
const utils_1 = require("../utils");
function wrapWithDebug(device, cb) {
let i = 0;
const originalMethods = {
sendFeatureReport: device.sendFeatureReport.bind(device),
getFeatureReport: device.getFeatureReport.bind(device),
getDeviceInfo: device.getDeviceInfo.bind(device),
};
return new Proxy(device, {
get(target, prop) {
if ((0, tsafe_1.typeGuard)(prop, prop in originalMethods)) {
const originalMethod = originalMethods[prop];
return (...args) => {
const callId = i++;
cb(`call-${String(prop)}`, { callId }, ...args);
// @ts-expect-error Bad type inference, but we know the types are correct
const result = originalMethod(...args);
if (result instanceof Promise) {
return result
.then((res) => {
cb(`result-${String(prop)}`, { callId }, res);
return res;
})
.catch((err) => {
cb(`error-${String(prop)}`, { callId }, err);
throw err;
});
}
return result;
};
}
return Reflect.get(target, prop, target);
},
});
}
/**
* Main class responsible for controlling BlinkStick devices.
* @category Core
*/
class BlinkStick {
/**
*
* @internal This is not intended to be used outside of the library. End-users should use `find*` functions
*/
constructor(device) {
this.abortController = new AbortController();
/**
* Changes the default retry count for sending feature reports.
*/
this.defaultRetryCount = 5;
this._inverse = false;
this.debugWriteStream = null;
this.isLinux = process.platform === 'linux';
this.interpretParameters = interpret_parameters_1.interpretParameters;
this.commandDebug = process.env.BLINKSTICK_DEBUG ?? null;
if (this.commandDebug) {
this.device = wrapWithDebug(device, (type, ...args) => this.writeDebugCommand(type, ...args));
}
else {
this.device = device;
}
this.deviceInfo = device.getDeviceInfo();
this.serial = this.deviceInfo.serialNumber ?? '';
this.manufacturer = this.deviceInfo.manufacturer ?? '';
this.product = this.deviceInfo.product ?? '';
const [versionMajor, versionMinor] = this.serial.split('-').pop().split('.').map(Number);
this.versionMajor = versionMajor;
this.versionMinor = versionMinor;
this.deviceDescription = (0, device_descriptions_1.attemptToGetDeviceDescription)(this.deviceInfo);
this.ledCount = this.deviceDescription?.ledCount ?? 0;
if (this.ledCount === 0) {
process.emitWarning(`Device ${this.product} does not have a known LED count.`, {
code: `BlinkStickUnknownLedCount-${this.serial}`,
...this.deviceInfo,
});
}
// This seems relevant only for a base device?
this.requiresSoftwareColorPatch =
this.versionMajor == 1 && this.versionMinor >= 1 && this.versionMinor <= 3;
}
get inverse() {
return this._inverse;
}
set inverse(value) {
this._inverse = value;
if (this._inverse) {
this.interpretParameters = interpret_parameters_1.interpretParametersInversed;
}
}
/**
* This allows BlinkStick instances to be used with explicit resource management
*/
async [Symbol.asyncDispose]() {
await this.close();
}
/**
* Gets animation runner for the device.
* If device has unknown number of LEDs, getter will return null.
* If device is in inverse mode, getter will return null.
*/
get animation() {
if (this.inverse) {
return null;
}
if (this._animation) {
return this._animation;
}
if (this.ledCount) {
this._animation = new animation_runner_1.AnimationRunner(this);
return this._animation;
}
else {
return null;
}
}
/**
* Low-level method that directly sends a feature report to the device.
* @param data
*/
async sendFeatureReport(data) {
return await this.device.sendFeatureReport((0, as_buffer_1.asBuffer)(data));
}
/**
* Returns the serial number of device.
*
* <pre>
* BSnnnnnn-1.0
* || | | |- Software minor version
* || | |--- Software major version
* || |-------- Denotes sequential number
* ||----------- Denotes BlinkStick device
* </pre>
*
* Software version defines the capabilities of the device
* @deprecated Use `.serial` directly
*/
getSerial() {
return this.serial;
}
/**
* Close BlinkStick device and stop all animations
*/
async close() {
blinkstick_finalization_registry_1.blinkstickFinalizationRegistry.unregister(this);
await this.stop();
await this.device.close();
if (this.debugWriteStream) {
await (0, node_util_1.promisify)((cb) => this.debugWriteStream.end(cb))();
this.debugWriteStream = null;
}
}
/**
* Stop all animations
*/
async stop() {
await this.animation?.stop();
this.abortController.abort();
this.abortController = new AbortController();
}
/**
* Set the color of LEDs
*
* @example
* //Available overloads
* setColor(red, green, blue, [options]); // use [0..255] ranges for intensity
*
* setColor(color, [options]); // use '#rrggbb' format
*
* setColor(color_name, [options]); // use 'random', 'red', 'green', 'yellow' and other CSS supported names
*
*/
async setColor(...options) {
const { interpretParameters } = this;
const params = interpretParameters(...options);
if (this.requiresSoftwareColorPatch) {
// eslint-disable-next-line prefer-const
let [cr, cg, cb] = await this.getColor();
if (params.r == cg && params.g == cr && params.b == cb) {
// TODO: figure out why original code never changed cb here.
if (cr > 0) {
cr = cr - 1;
}
else if (cg > 0) {
cg = cg - 1;
}
await this.sendColor(interpretParameters(cr, cg, cb));
await this.sendColor(interpretParameters(params.r, params.g, params.b));
}
else {
await this.sendColor(interpretParameters(params.r, params.g, params.b));
}
return;
}
else {
await this.sendColor(params);
}
}
async sendColor(params) {
const { r, g, b, options: { channel = 0, index = 0 }, } = params;
if (params.options.channel === 0 && params.options.index === 0) {
return await this.setFeatureReport(node_buffer_1.Buffer.from([types_1.FeatureReportId.SetFirst, r, g, b]));
}
else {
return await this.setFeatureReport(node_buffer_1.Buffer.from([types_1.FeatureReportId.SetArbitraryPixel, channel, index, r, g, b]));
}
}
/**
* Set inverse mode for IKEA DIODER in conjunction with BlinkStick Pro
* Seemingly, this is effectively deprecated in favor of setting mode on BlinkStick Pro directly.
*
* @param {Boolean} inverse Set true for inverse mode and false otherwise
* @deprecated Use `.inverse` directly
*/
setInverse(inverse) {
this.inverse = inverse;
}
/**
* @deprecated Use `.inverse` directly
*/
getInverse() {
return this.inverse;
}
/**
* Set mode for BlinkStick Pro. This will persist across reboots.
*
* - 0 = Normal
* - 1 = Inverse
* - 2 = WS2812
*
* You can read more about BlinkStick modes by following this link:
*
* http://www.blinkstick.com/help/tutorials/blinkstick-pro-modes
*/
async setMode(mode) {
await this.setFeatureReport((0, as_buffer_1.asBuffer)([types_1.FeatureReportId.SetMode, mode]));
}
/**
* Gets the current mode.
*/
async getMode() {
return (0, as_buffer_1.asBuffer)(await this.getFeatureReport(types_1.FeatureReportId.SetMode, 33))[1];
}
/**
* Sets the number of LEDs on "supported" devices.
* Note that unplugging and plugging the device may be necessary for the change to take effect.
* @param count
* @experimental
*/
async setLedCountAtDevice(count) {
(0, tsafe_1.assert)(count > 0, 'LED count must be greater than 0');
(0, tsafe_1.assert)(count <= 255, 'LED count must be less than or equal to 255');
// self._usb_ctrl_transfer(0x20, 0x9, 0x81, 0, control_string)
const report = node_buffer_1.Buffer.from(new Uint8Array([0x81, count]));
await this.sendFeatureReport(report);
this.ledCount = count;
this._animation?.stop();
this._animation = undefined;
}
/**
* Gets the number of LEDs from the device.
* @experimental
*/
async getLedCountFromDevice() {
const ret = await this.getFeatureReport(0x81, 2);
(0, tsafe_1.assert)(ret.length === 2, `Expected report length to be 2, got ${ret.length}`);
(0, tsafe_1.assert)(ret[1] > 0, `LED count must be greater than 0 (found: ${ret[1]})`);
return ret[1];
}
/**
* Loads the LED count from the device on "supported" devices.
* @experimental
*/
async loadLedCountFromDevice() {
this.ledCount = await this.getLedCountFromDevice();
this._animation?.stop();
this._animation = undefined;
return this;
}
/**
* Set a random color on BlinkStick.
* @param index The index of the LED, 0 is default
*/
async setRandomColor(index = 0) {
return await this.setColor((0, utils_1.getRandomColor)(), { index });
}
/**
* Get the current color visible on BlinkStick
* @param index The index of the LED, 0 is default
*/
async getColor(index = 0) {
if (index === 0) {
const buffer = await this.getFeatureReport(0x0001, 33);
if (buffer) {
return [buffer[1], buffer[2], buffer[3]];
}
else {
return [0, 0, 0];
}
}
else {
const colors = await this.getColors(index);
return [colors[index * 3 + 1], colors[index * 3], colors[index * 3 + 2]];
}
}
/**
* Get the current color frame on BlinkStick Pro
* @example
* .getColors(8);
* @return {Array} Callback returns an array of LED data in the following format: [g0, r0, b0, g1, r1, b1...]
* * */
async getColors(count) {
const { reportId, maxLeds } = (0, determine_report_id_1.determineReportId)(count * 3);
const report = await this.getFeatureReport(reportId, maxLeds * 3 + 2);
return report.subarray(2, report.length - 1);
}
/**
* Set the color frame on BlinkStick Pro
* Missing colors are filled with zeros.
* Note - this method seemingly has DIFFERENT behavior on different devices.
* @param channel Channel is represented as 0=R, 1=G, 2=B
* @param data LED data in the following format: [g0, r0, b0, g1, r1, b1...]
*/
async setColors(channel, data) {
const { reportId, maxLeds } = (0, determine_report_id_1.determineReportId)(data.length);
if (this.isLinux && data.length > 63) {
// Linux has a limit of 65 bytes for feature reports, so we need to split the data into multiple reports
return await this.setBigColorsOnLinux(channel, (0, as_buffer_1.asBuffer)(data));
}
const maxDataBytes = maxLeds * 3;
const offset = 2;
const reportLength = offset + maxDataBytes;
const report = node_buffer_1.Buffer.alloc(reportLength);
report[0] = reportId;
report[1] = channel;
const copyLength = Math.min(data.length, maxDataBytes);
const source = (0, as_buffer_1.asBuffer)(data);
source.copy(report, offset, 0, copyLength);
await this.setFeatureReport((0, as_buffer_1.asBuffer)(report));
}
/**
* A workaround for setting colors on Flex on Linux.
* @param channel
* @param data
*/
async setBigColorsOnLinux(channel, data) {
(0, tsafe_1.assert)(data.length % 3 === 0, 'Data length must be a multiple of 3');
const ledOffset = 16; // First 16 LEDs are set with a single feature report
const first16LedsData = data.subarray(0, ledOffset * 3);
await this.sendFeatureReport(node_buffer_1.Buffer.from([types_1.FeatureReportId.Set16Pixels, channel, ...first16LedsData]));
const buff = (0, as_buffer_1.asBuffer)(data).subarray(ledOffset * 3); // Skip first 16 LEDs, as we use bulk transfer for those
// const buff = asBuffer(data);
const rgbTuples = [];
for (let i = 0; i < buff.length; i += 3) {
const ledIndex = i + ledOffset; // Start from 16, as we already set first 16 LEDs
// Here data is in GRB format, so we need to convert it to RGB
const r = buff[i + 1] | 0;
const g = buff[i] | 0;
const b = buff[i + 2] | 0;
rgbTuples.push({ rgb: [r, g, b], ledIndex });
}
const shuffled = rgbTuples.sort(() => Math.random() - 0.5);
for (const { rgb: [r, g, b], ledIndex, } of shuffled) {
try {
await (0, retry_n_times_1.retryNTimes)(3, () => this.sendFeatureReport(node_buffer_1.Buffer.from([types_1.FeatureReportId.SetArbitraryPixel, channel, ledIndex, r, g, b])), { ledIndex }, (n) => promises_2.scheduler.wait((n + 1) ** 2 * 10));
}
catch (err) {
if (ledIndex > 22) {
// Did you know? BlinkStick Flex firmware is buggy and LEDs 23+ are not supported?
continue;
}
throw err;
}
}
}
/**
* Get the current color as hex string.
* @param index The index of the LED, 0 is default
* @deprecated Use utils functions instead
*/
async getColorString(index = 0) {
const [r, g, b] = await this.getColor(index);
return `#${(0, decimal_to_hex_1.decimalToHex)(r, 2)}${(0, decimal_to_hex_1.decimalToHex)(g, 2)}${(0, decimal_to_hex_1.decimalToHex)(b, 2)}`;
}
/**
* Get the infoblock1 of the device.
* This is a 32 byte array that can contain any data. It's supposed to
* hold the "Name" of the device making it easier to identify rather than
* a serial number.
*/
async getInfoBlock1() {
return await (0, get_info_block_1.getInfoBlockRaw)(this, 0x0002);
}
/**
* Sets the infoblock1 with specified string.
* It fills the rest of bytes with zeros.
*
* Usage:
*
* @example
* ```
* setInfoBlock1(Buffer.from("abcdefg", 'hex'));
* ```
* @param data
*/
async setInfoBlock1(data) {
return await (0, set_info_block_1.setInfoBlock)(this, types_1.FeatureReportId.InfoBlock1, data);
}
/**
* Get the infoblock2 of the device.
* This is a 32 byte array that can contain any data.
*/
async getInfoBlock2() {
return await (0, get_info_block_1.getInfoBlockRaw)(this, types_1.FeatureReportId.InfoBlock2);
}
async setInfoBlock2(data) {
return await (0, set_info_block_1.setInfoBlock)(this, types_1.FeatureReportId.InfoBlock2, data);
}
turnOff(index = 0) {
return this.setColor(0, 0, 0, { index });
}
async turnOffAll() {
await this.leds().setColor([0, 0, 0]);
}
/**
* @deprecated Use Animation API instead
*/
async blink(...options) {
const { interpretParameters } = this;
const params = interpretParameters(...options);
const abortSignal = AbortSignal.any([this.abortController.signal, params.options.signal].filter(is_defined_1.isDefined));
const repeats = params.options.repeats ?? 1;
const delay = params.options.delay ?? 500;
for (let i = 0; i < repeats; i++) {
await this.setColor(params.r, params.g, params.b, params.options);
await (0, promises_1.setTimeout)(delay, null, { signal: abortSignal });
await this.setColor(0, 0, 0, params.options);
}
}
/**
* Set a feature report to the device, and returns read data from the device.
*
* @param reportId Report ID to receive
* @param data Data to send to the device
* @param maxRetries Maximum number of retries
*/
async setFeatureReportAndRead(reportId, data, maxRetries = this.defaultRetryCount) {
(0, tsafe_1.assert)(data[0] === reportId, 'First byte of data must be the report ID');
return await (0, retry_n_times_1.retryNTimes)(maxRetries, async () => {
await this.sendFeatureReport(data);
return (0, as_buffer_1.asBuffer)(await this.device.getFeatureReport(reportId, data.length));
});
}
/**
* Set a feature report to the device.
* @param data - First byte is report ID, the rest is data
* @param maxRetries
*/
async setFeatureReport(data, maxRetries = 5) {
await (0, retry_n_times_1.retryNTimes)(maxRetries, () => this.sendFeatureReport(data), {
reportId: data[0],
dataLength: data.length,
});
}
getDebugWriteStream() {
if (this.debugWriteStream) {
return this.debugWriteStream;
}
if (!this.commandDebug) {
throw new Error('Command debug is not enabled');
}
const filePath = this.commandDebug
.replace('%SERIAL', this.serial)
.replace('%RELEASE', String(this.deviceInfo.release))
.replace('%PID', String(process.pid))
.replace('%NAME', this.product);
process.emitWarning(`Debugging commands will be written to ${filePath}`);
this.debugWriteStream = (0, node_fs_1.createWriteStream)(filePath, { flags: 'a' });
return this.debugWriteStream;
}
/**
* This method is used to write debug commands to the debug stream.
* It should never be called if `commandDebug` is not set.
*/
writeDebugCommand(type, ...args) {
const debugInfo = {
serial: this.serial,
product: this.product,
release: this.deviceInfo.release,
pid: process.pid,
stack: new Error().stack
?.split(os.EOL)
.slice(2)
.map((v) => v.trim().replace(/at (async )?/, ''))
.join(os.EOL)
.replaceAll(process.cwd(), '.'),
};
this.getDebugWriteStream().write(JSON.stringify([debugInfo, type, ...args], (_k, v) => {
if (typeof v === 'bigint') {
return v.toString();
}
if (v instanceof node_buffer_1.Buffer) {
return {
type: 'Buffer',
data: (0, as_buffer_1.asBuffer)(v).toString('hex'),
length: v.length,
};
}
if (Array.isArray(v) && v.every((item) => typeof item === 'number')) {
return {
type: 'Buffer',
data: (0, as_buffer_1.asBuffer)(v).toString('hex'),
length: v.length,
};
}
if (v instanceof Error) {
return {
...v,
name: v.name,
constructorName: v.constructor.name,
message: v.message,
stack: v.stack,
};
}
return v;
}, 0) + os.EOL);
}
/**
* Morphs to specified RGB color from current color.
*
* Function supports the following overloads:
*
* @example
* //Available overloads
* morph(red, green, blue, [options], [callback]); // use [0..255] ranges for intensity
*
* morph(color, [options], [callback]); // use '#rrggbb' format
*
* morph(color_name, [options], [callback]); // use 'random', 'red', 'green', 'yellow' and other CSS supported names
*
* Options can contain the following parameters for object:
*
* - channel=0: Channel is represented as 0=R, 1=G, 2=B
* - index=0: The index of the LED
* - duration=1000: How long should the morph animation last in milliseconds
* - steps=50: How many steps for color changes
* @deprecated Use animation API instead
*/
async morph(...args) {
const params = (0, interpret_parameters_1.interpretParameters)(...args);
const duration = params.options.duration ?? 1000;
let steps = params.options.steps;
if (steps === undefined || steps <= 0 || duration / steps < 17) {
steps = (duration / 33) | 0; // 33ms is approximately 30 FPS
}
const stepDuration = Math.round(duration / steps);
const [cr, cg, cb] = await this.getColor(params.options.index ?? 0);
const signal = AbortSignal.any([this.abortController.signal, params.options.signal].filter(is_defined_1.isDefined));
for (let count = 0; count < steps; count++) {
const nextRed = (0, clamp_1.clampRgb)(cr + ((params.r - cr) / steps) * count);
const nextGreen = (0, clamp_1.clampRgb)(cg + ((params.g - cg) / steps) * count);
const nextBlue = (0, clamp_1.clampRgb)(cb + ((params.b - cb) / steps) * count);
const t0 = performance.now();
await this.setColor(nextRed, nextGreen, nextBlue, params.options);
const elapsedTime = performance.now() - t0;
const sleepTime = stepDuration - elapsedTime;
if (sleepTime > 0) {
await (0, promises_1.setTimeout)(sleepTime, null, { signal });
}
else if (this.isSync) {
// If we are in sync mode, release the event loop to allow other operations
await promises_2.scheduler.yield();
}
}
}
/**
* Pulses specified RGB color.
*
* Function supports the following overloads:
*
* @example
* //Available overloads
* pulse(red, green, blue, [options], [callback]); // use [0..255] ranges for intensity
*
* pulse(color, [options], [callback]); // use '#rrggbb' format
*
* pulse(color_name, [options], [callback]); // use 'random', 'red', 'green', 'yellow' and other CSS supported names
*
* Options can contain the following parameters for object:
*
* - channel=0: Channel is represented as 0=R, 1=G, 2=B
* - index=0: The index of the LED
* - duration=1000: How long should the pulse animation last in milliseconds (this time is actually doubled, keeping it in-line with legacy behavior)
* - steps=50: How many steps for color changes
* @deprecated Use animation API instead
*/
async pulse(...options) {
const params = (0, interpret_parameters_1.interpretParameters)(...options);
await this.morph(params.r, params.g, params.b, params.options);
await this.morph(0, 0, 0, params.options);
}
/**
* Get a feature report from the device, retrying if necessary.
*
* @param {Number} reportId Report ID to receive
* @param {Number} length Expected length of the report
* @param {Number} maxRetries Maximum number of retries
*/
async getFeatureReport(reportId, length, maxRetries = this.defaultRetryCount) {
return (0, retry_n_times_1.retryNTimes)(maxRetries, async () => this.getFeatureReportRaw(reportId, length));
}
/**
* Gets a feature report from the device without retries.
* @param reportId
* @param length
*/
async getFeatureReportRaw(reportId, length) {
return (0, as_buffer_1.asBuffer)(await this.device.getFeatureReport(reportId, length));
}
/**
* Gets API to control all LEDs on the device.
*/
leds() {
return new led_group_1.LedGroup(this);
}
/**
* Gets API to control a single LED on the device.
* @param index
*/
led(index) {
(0, tsafe_1.assert)(index >= 0, 'Index must be greater than 0');
(0, tsafe_1.assert)(index < this.ledCount, 'Index must be less than ledCount');
return new led_1.Led(this, index);
}
getTransport() {
return this.device;
}
}
exports.BlinkStick = BlinkStick;
//# sourceMappingURL=blinkstick.js.map