lifxlan
Version:
TypeScript library for controlling LIFX products over LAN
256 lines • 9.91 kB
JavaScript
;
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