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
JavaScript
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