UNPKG

@ginden/blinkstick-v2

Version:
730 lines 29 kB
"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"); const id_manager_1 = require("./id-manager"); const customInspectSymbol = Symbol.for('nodejs.util.inspect.custom'); 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); }, }); } /** * @summary 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; /** * @hidden */ this._inverse = false; /** * @hidden */ 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; } } /** * Get ID of this specific device. * It's stored in infoBlock2, so using this property is incompatible with using infoBlock2 for other purposes. */ get id() { return new id_manager_1.DeviceIdManager(this); } /** * @summary 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; } /** * @summary 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 33 byte Buffer 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. * First byte is report ID, the rest is data. */ async getInfoBlock1() { return await (0, get_info_block_1.getInfoBlockRaw)(this, types_1.FeatureReportId.InfoBlock1); } /** * 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 33 byte array that can contain any data. * First byte is report ID, the rest is data. * Using it is INCOMPATIBLE with DeviceIdManager. */ async getInfoBlock2() { return await (0, get_info_block_1.getInfoBlockRaw)(this, types_1.FeatureReportId.InfoBlock2); } /** * Sets the infoblock2 with specified Buffer of length of 33. * The first byte of the buffer is report ID, the rest is data. * @param data */ 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; } get [customInspectSymbol]() { return { $type: this.constructor.name, serial: this.serial, manufacturer: this.manufacturer, product: this.product, version: `${this.versionMajor}.${this.versionMinor}`, ledCount: this.ledCount, }; } } exports.BlinkStick = BlinkStick; //# sourceMappingURL=blinkstick.js.map