UNPKG

lifxlan

Version:

TypeScript library for controlling LIFX products over LAN

256 lines 9.91 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Client = Client; const index_js_1 = require("./constants/index.js"); const encoding_js_1 = require("./encoding.js"); const index_js_2 = require("./utils/index.js"); const errors_js_1 = require("./errors.js"); /** * Creates a unique response key for correlating requests with responses. * * @param serialNumber - The device serial number * @param sequence - The message sequence number * @returns A unique key for this request-response pair * @internal * @performance Critical path - string concatenation optimized for V8 */ function getResponseKey(serialNumber, sequence) { return `${serialNumber}:${sequence}`; } /** * Increments the sequence number for message ordering. * * Sequence numbers are limited to 0-254 (255 is reserved for broadcast messages). * This ensures proper message ordering and prevents conflicts with broadcast operations. * * @param sequence - Current sequence number, undefined for initial sequence * @returns Next sequence number (0-254) * @internal * @performance Bitwise operations for maximum speed */ function incrementSequence(sequence) { /** Only allow up to 254. We use 255 for broadcast messages. */ return sequence == null ? 0 : (sequence + 1) % 0xFF; } function registerHandler(ackMode, serialNumber, sequence, decode, defaultTimeoutMs, responseHandlerMap, signal) { const key = getResponseKey(serialNumber, sequence); if (responseHandlerMap.has(key)) { throw new errors_js_1.MessageConflictError(key, sequence); } const { resolve, reject, promise } = (0, index_js_2.PromiseWithResolvers)(); let receivedAck = false; let receivedResponse = false; let responseResult; function onAbort(errOrEvent) { responseHandlerMap.delete(key); if (errOrEvent instanceof Error) { reject(errOrEvent); } else { reject(new errors_js_1.AbortError('device response')); } } let timeout; if (signal) { signal.addEventListener('abort', onAbort, { once: true }); } else if (defaultTimeoutMs > 0) { timeout = setTimeout(() => onAbort(new errors_js_1.TimeoutError(defaultTimeoutMs, 'device response')), defaultTimeoutMs); } function cleanupOnResponse() { if (signal) { signal.removeEventListener('abort', onAbort); } else if (timeout) { clearTimeout(timeout); } responseHandlerMap.delete(key); } function checkForCompletion() { if (ackMode === 'ack-only' && receivedAck) { cleanupOnResponse(); resolve(undefined); return true; } if (ackMode === 'response' && receivedResponse) { cleanupOnResponse(); resolve(responseResult); return true; } if (ackMode === 'both' && receivedAck && receivedResponse) { cleanupOnResponse(); resolve(responseResult); return true; } return false; } responseHandlerMap.set(key, (type, bytes, offsetRef) => { if (type === index_js_1.Type.Acknowledgement) { receivedAck = true; checkForCompletion(); return; } if (type === index_js_1.Type.StateUnhandled) { cleanupOnResponse(); const requestType = (0, encoding_js_1.decodeStateUnhandled)(bytes, offsetRef); reject(new errors_js_1.UnhandledCommandError(requestType, serialNumber)); return; } if (decode) { // Support both single-response and multi-response commands const continuation = { expectMore: false }; // Check if this is a multi-response command that accepts responseType parameter const result = decode.length >= 4 ? decode(bytes, offsetRef, continuation, type) : decode(bytes, offsetRef, continuation); if (continuation.expectMore) { // Don't cleanup or resolve yet - wait for more responses return; } else { // This is the final response or a single-response command receivedResponse = true; responseResult = result; checkForCompletion(); } } }); return promise; } // Define response modes as const to get literal types const RESPONSE_MODES = ['auto', 'ack-only', 'response', 'both']; /** * Creates a high-level client for communicating with LIFX devices. * * The Client provides methods for sending commands to devices with automatic * timeout handling, retry logic, and response correlation. It uses the Router * for message routing and supports both acknowledged and unacknowledged messaging patterns. * * @param options Configuration options * @returns A new client instance * @example * ```javascript * const client = Client({ router }); * const response = await client.send(GetColorCommand(), device); * ``` * @performance Optimized for high-throughput scenarios with minimal allocations */ function Client(options) { const source = options.source ?? options.router.nextSource(); const defaultTimeoutMs = options.defaultTimeoutMs ?? 3000; const { router } = options; const responseHandlerMap = new Map(); let disposed = false; const client = { /** * @readonly * @returns The router instance */ get router() { return router; }, /** * @readonly * @returns The client's unique source identifier */ get source() { return source; }, /** * Disposes of the client and releases its source identifier. * * Call this when creating many short-lived clients to prevent source exhaustion. * Once disposed, the client cannot be used for further operations. * * @example * ```javascript * const client = Client({ router }); * // ... use client * client.dispose(); // Free up resources * ``` */ dispose() { if (disposed) return; disposed = true; // Clear all pending response handlers for (const handler of responseHandlerMap.values()) { try { handler(0, new Uint8Array(), { current: 0 }); } catch { // Ignore errors during disposal cleanup } } responseHandlerMap.clear(); router.deregister(source, client.onMessage); }, /** * Broadcast a command to the local network. */ broadcast(command) { if (disposed) throw new errors_js_1.DisposedClientError(source); const bytes = (0, encoding_js_1.encode)(true, source, index_js_1.NO_TARGET, false, false, 0xFF, command.type, command.payload); router.send(bytes, index_js_1.PORT, index_js_1.BROADCAST_ADDRESS); }, /** * Send a command to a device without expecting a response or acknowledgement. */ unicast(command, device) { if (disposed) throw new errors_js_1.DisposedClientError(source); const bytes = (0, encoding_js_1.encode)(false, source, device.target, false, false, device.sequence, command.type, command.payload); router.send(bytes, device.port, device.address, device.serialNumber); device.sequence = incrementSequence(device.sequence); }, /** * Send a command to a device with configurable acknowledgment behavior. */ send(command, device, options) { if (disposed) throw new errors_js_1.DisposedClientError(source); // Determine response mode let ackMode; if (options?.responseMode === 'auto' || !options?.responseMode) { // Use command's default response mode ackMode = command.defaultResponseMode ?? 'response'; } else { ackMode = options.responseMode; } // Determine protocol flags based on response mode let resRequired = false; let ackRequired = false; switch (ackMode) { case 'ack-only': ackRequired = true; break; case 'response': resRequired = true; break; case 'both': resRequired = true; ackRequired = true; break; } const bytes = (0, encoding_js_1.encode)(false, source, device.target, resRequired, ackRequired, device.sequence, command.type, command.payload); const promise = registerHandler(ackMode, device.serialNumber, device.sequence, command.decode, defaultTimeoutMs, responseHandlerMap, options?.signal); device.sequence = incrementSequence(device.sequence); router.send(bytes, device.port, device.address, device.serialNumber); return promise; }, onMessage(header, payload, serialNumber) { if (options.onMessage) { options.onMessage(header, payload, serialNumber); } const responseHandlerEntry = responseHandlerMap.get(getResponseKey(serialNumber, header.sequence)); if (responseHandlerEntry) { responseHandlerEntry(header.type, payload, { current: 0 }); } }, }; router.register(source, client.onMessage); return client; } //# sourceMappingURL=client.js.map