UNPKG

node-switchbot

Version:

The node-switchbot is a Node.js module which allows you to control your Switchbot Devices through Bluetooth (BLE).

366 lines 16.7 kB
import crypto, { randomUUID } from 'node:crypto'; import { EventEmitter } from 'node:events'; import { createServer } from 'node:http'; import { request } from 'undici'; import { updateBaseURL, urls } from './settings.js'; /** * Custom error class for API errors. */ class APIError extends Error { statusCode; constructor(message, statusCode) { super(message); this.statusCode = statusCode; this.name = 'APIError'; } } /** * The `SwitchBotOpenAPI` class provides methods to interact with the SwitchBot OpenAPI. * It allows you to retrieve device information, control devices, and manage webhooks. * * @extends EventEmitter * * @example * ```typescript * const switchBotAPI = new SwitchBotOpenAPI('your-token', 'your-secret'); * * // Get devices * switchBotAPI.getDevices().then(response => { * console.log(response); * }).catch(error => { * console.error(error); * }); * * // Control a device * switchBotAPI.controlDevice('device-id', 'turnOn', 'default').then(response => { * console.log(response); * }).catch(error => { * console.error(error); * }); * * // Setup webhook * switchBotAPI.setupWebhook('http://your-webhook-url').then(() => { * console.log('Webhook setup successfully'); * }).catch(error => { * console.error(error); * }); * ``` * * @param {string} token - The API token used for authentication. * @param {string} secret - The secret key used for signing requests. */ export class SwitchBotOpenAPI extends EventEmitter { token; secret; baseURL; webhookEventListener = null; /** * Creates an instance of the SwitchBot OpenAPI client. * * @param token - The API token used for authentication. * @param secret - The secret key used for signing requests. */ constructor(token, secret, hostname) { super(); this.token = token; this.secret = secret; this.emitLog('info', `Token: ${token}, Secret: ${secret}`); this.baseURL = urls.baseURL; if (hostname) { updateBaseURL(hostname); } } /** * Emits a log event with the specified log level and message. * * @param level - The severity level of the log (e.g., 'info', 'warn', 'error'). * @param message - The log message to be emitted. */ async emitLog(level, message) { this.emit('log', { level, message }); } /** * Generates the headers required for authentication with the SwitchBot OpenAPI. * * @param configToken - The token used for authorization. * @param configSecret - The secret key used to sign the request. * @returns An object containing the necessary headers: * - `Authorization`: The authorization token. * - `sign`: The HMAC-SHA256 signature of the token, timestamp, and nonce. * - `nonce`: A unique identifier for the request. * - `t`: The current timestamp in milliseconds. * - `Content-Type`: The content type of the request, set to 'application/json'. */ generateHeaders = (configToken, configSecret) => { const t = Date.now().toString(); const nonce = randomUUID(); const data = configToken + t + nonce; const sign = crypto .createHmac('sha256', configSecret) .update(data) .digest('base64'); return { 'Authorization': configToken, 'sign': sign, 'nonce': nonce, 't': t, 'Content-Type': 'application/json', }; }; /** * Retrieves the list of devices from the SwitchBot OpenAPI. * @param token - (Optional) The token used for authentication. If not provided, the instance token will be used. * @param secret - (Optional) The secret used for authentication. If not provided, the instance secret will be used. * @returns {Promise<{ response: body, statusCode: number }>} A promise that resolves to an object containing the API response. * @throws {Error} Throws an error if the request to get devices fails. */ async getDevices(token, secret) { const url = urls.devicesURL; try { const configToken = token || this.token; const configSecret = secret || this.secret; const { body, statusCode } = await request(url, { headers: this.generateHeaders(configToken, configSecret) }); const response = await body.json(); this.emitLog('debug', `Got devices: ${JSON.stringify(response)}`); this.emitLog('debug', `statusCode: ${statusCode}`); return { response, statusCode }; } catch (e) { this.emitLog('error', `Failed to get devices: ${e.message ?? e}`); throw new APIError(`Failed to get devices: ${e.message ?? e}`, e.statusCode); } } /** * Controls a device by sending a command to the SwitchBot API. * * @param deviceId - The ID of the device to control. * @param command - The command to send to the device. * @param parameter - The parameter for the command. * @param commandType - The type of the command (default is 'command'). * @param token - (Optional) The token used for authentication. If not provided, the instance token will be used. * @param secret - (Optional) The secret used for authentication. If not provided, the instance secret will be used. * @returns A promise that resolves to an object containing the response body and status code. * @throws An error if the device control fails. */ async controlDevice(deviceId, command, parameter, commandType = 'command', token, secret) { try { const configToken = token || this.token; const configSecret = secret || this.secret; const { body, statusCode } = await request(`${urls.devicesURL}/${deviceId}/commands`, { method: 'POST', headers: this.generateHeaders(configToken, configSecret), body: JSON.stringify({ command, parameter, commandType, }), }); const response = await body.json(); this.emitLog('debug', `Controlled device: ${deviceId} with command: ${command} and parameter: ${parameter}`); this.emitLog('debug', `statusCode: ${statusCode}`); return { response, statusCode }; } catch (e) { this.emitLog('error', `Failed to control device: ${e.message ?? e}`); throw new APIError(`Failed to control device: ${e.message ?? e}`, e.statusCode); } } /** * Retrieves the status of a specific device. * * @param deviceId - The unique identifier of the device. * @param token - (Optional) The token used for authentication. If not provided, the instance token will be used. * @param secret - (Optional) The secret used for authentication. If not provided, the instance secret will be used. * @returns A promise that resolves to an object containing the device status and the status code of the request. * @throws An error if the request fails. */ async getDeviceStatus(deviceId, token, secret) { try { const configToken = token || this.token; const configSecret = secret || this.secret; const { body, statusCode } = await request(`${urls.devicesURL}/${deviceId}/status`, { headers: this.generateHeaders(configToken, configSecret) }); const { body: response } = await body.json(); this.emitLog('debug', `Got device status: ${deviceId}`); this.emitLog('debug', `statusCode: ${statusCode}`); return { response, statusCode }; } catch (error) { this.emitLog('error', `Failed to get device status: ${error.message}`); throw new Error(`Failed to get device status: ${error.message}`); } } /** * Sets up a webhook listener and configures the webhook on the server. * * This method performs the following steps: * 1. Creates a local server to listen for incoming webhook events. * 2. Sends a request to set up the webhook with the provided URL. * 3. Sends a request to update the webhook configuration. * 4. Sends a request to query the current webhook URL. * * @param url - The URL to which the webhook events will be sent. * @param token - (Optional) The token used for authentication. If not provided, the instance token will be used. * @param secret - (Optional) The secret used for authentication. If not provided, the instance secret will be used. * @returns A promise that resolves when the webhook setup is complete. * * @throws Will log an error if any step in the webhook setup process fails. */ async setupWebhook(url, token, secret) { try { const xurl = new URL(url); const port = Number(xurl.port); const path = xurl.pathname; this.webhookEventListener = createServer(async (request, response) => { try { if (request.url === path && request.method === 'POST') { request.on('data', async (data) => { try { const body = JSON.parse(data); await this.emitLog('debug', `Received Webhook: ${JSON.stringify(body)}`); this.emit('webhookEvent', body); } catch (e) { await this.emitLog('error', `Failed to handle webhook event data, Error: ${e.message ?? e}`); } }); response.writeHead(200, { 'Content-Type': 'text/plain' }); response.end('OK'); } else { await this.emitLog('error', `Invalid request received. URL:${request.url}, Method:${request.method}`); response.writeHead(403, { 'Content-Type': 'text/plain' }); response.end(`NG`); } } catch (e) { await this.emitLog('error', `Failed to handle webhook event, Error: ${e.message ?? e}`); } }).listen(port || 80); } catch (e) { await this.emitLog('error', `Failed to create webhook listener, Error: ${e.message ?? e}`); throw new APIError(`Failed to create webhook listener: ${e.message ?? e}`, e.statusCode); } try { const configToken = token || this.token; const configSecret = secret || this.secret; const requestOptions = { method: 'POST', headers: this.generateHeaders(configToken, configSecret), body: JSON.stringify({ action: 'setupWebhook', url, deviceList: 'ALL', }), timeout: 20000, // Increase timeout to 20 seconds }; const { body, statusCode } = await requestWithRetry(urls.setupWebhook, requestOptions); const response = await body.json(); await this.emitLog('debug', `setupWebhook: url:${url}, body:${JSON.stringify(response)}, statusCode:${statusCode}`); if (statusCode !== 200 || response?.statusCode !== 100) { await this.emitLog('error', `Failed to configure webhook. Existing webhook well be overridden. HTTP:${statusCode} API:${response?.statusCode} message:${response?.message}`); } } catch (e) { await this.emitLog('error', `Failed to configure webhook, Error: ${e.message ?? e}`); throw new APIError(`Failed to configure webhook: ${e.message ?? e}`, e.statusCode); } try { const configToken = token || this.token; const configSecret = secret || this.secret; const { body, statusCode } = await request(urls.updateWebhook, { method: 'POST', headers: this.generateHeaders(configToken, configSecret), body: JSON.stringify({ action: 'updateWebhook', config: { url, enable: true, }, }), }); const response = await body.json(); await this.emitLog('debug', `updateWebhook: url:${url}, body:${JSON.stringify(response)}, statusCode:${statusCode}`); if (statusCode !== 200 || response?.statusCode !== 100) { await this.emitLog('error', `Failed to update webhook. HTTP:${statusCode} API:${response?.statusCode} message:${response?.message}`); } } catch (e) { await this.emitLog('error', `Failed to update webhook, Error: ${e.message ?? e}`); throw new APIError(`Failed to update webhook: ${e.message ?? e}`, e.statusCode); } try { const configToken = token || this.token; const configSecret = secret || this.secret; const { body, statusCode } = await request(urls.queryWebhook, { method: 'POST', headers: this.generateHeaders(configToken, configSecret), body: JSON.stringify({ action: 'queryUrl', }), }); const response = await body.json(); await this.emitLog('debug', `queryWebhook: body:${JSON.stringify(response)}, statusCode:${statusCode}`); if (statusCode !== 200 || response?.statusCode !== 100) { await this.emitLog('error', `Failed to query webhook. HTTP:${statusCode} API:${response?.statusCode} message:${response?.message}`); } else { await this.emitLog('info', `Listening webhook on ${response?.body?.urls[0]}`); } } catch (e) { await this.emitLog('error', `Failed to query webhook, Error: ${e.message ?? e}`); throw new APIError(`Failed to query webhook: ${e.message ?? e}`, e.statusCode); } } /** * Deletes a webhook by sending a request to the specified URL. * * @param url - The URL of the webhook to be deleted. * @param token - (Optional) The token used for authentication. If not provided, the instance token will be used. * @param secret - (Optional) The secret used for authentication. If not provided, the instance secret will be used. * @returns A promise that resolves when the webhook is successfully deleted. * * @throws Will log an error if the deletion fails. */ async deleteWebhook(url, token, secret) { try { const configToken = token || this.token; const configSecret = secret || this.secret; const { body, statusCode } = await request(urls.deleteWebhook, { method: 'POST', headers: this.generateHeaders(configToken, configSecret), body: JSON.stringify({ action: 'deleteWebhook', url, }), }); const response = await body.json(); await this.emitLog('debug', `deleteWebhook: url:${url}, body:${JSON.stringify(response)}, statusCode:${statusCode}`); if (statusCode !== 200 || response?.statusCode !== 100) { await this.emitLog('error', `Failed to delete webhook. HTTP:${statusCode} API:${response?.statusCode} message:${response?.message}`); } else { await this.emitLog('info', 'Unregistered webhook to close listening.'); } } catch (e) { await this.emitLog('error', `Failed to delete webhook, Error: ${e.message ?? e}`); throw new APIError(`Failed to delete webhook: ${e.message ?? e}`, e.statusCode); } } } async function requestWithRetry(url, options, retries = 3) { for (let attempt = 1; attempt <= retries; attempt++) { try { return await request(url, options); } catch (error) { if (attempt === retries) { throw error; } await new Promise(resolve => setTimeout(resolve, 1000 * attempt)); // Exponential backoff } } } //# sourceMappingURL=switchbot-openapi.js.map