tsvesync
Version:
A TypeScript library for interacting with VeSync smart home devices
699 lines (614 loc) • 23.6 kB
text/typescript
/**
* VeSync API Device Library
*/
import { Helpers, API_RATE_LIMIT, DEFAULT_TZ, setApiBaseUrl, getApiBaseUrl } from './helpers';
import { VeSyncBaseDevice } from './vesyncBaseDevice';
import { fanModules } from './vesyncFanImpl';
import { outletModules } from './vesyncOutletImpl';
import { switchModules } from './vesyncSwitchImpl';
import { bulbModules } from './vesyncBulbImpl';
import { logger, setLogger, Logger } from './logger';
const DEFAULT_ENERGY_UPDATE_INTERVAL = 21600;
/**
* Device exclusion configuration
*/
export interface ExcludeConfig {
type?: string[];
model?: string[];
name?: string[];
namePattern?: string[];
id?: string[];
}
// Device constructor type for concrete device classes only
type DeviceConstructor = new (config: Record<string, any>, manager: VeSync) => VeSyncBaseDevice;
// Module dictionary type
interface DeviceModules {
[key: string]: DeviceConstructor;
}
// Device categories
type DeviceCategory = 'fans' | 'outlets' | 'switches' | 'bulbs';
/**
* Create device instance based on type
*/
function objectFactory(details: Record<string, any>, manager: VeSync): [string, VeSyncBaseDevice | null] {
const deviceType = details.deviceType as string;
let DeviceClass: DeviceConstructor | null = null;
let category = 'unknown';
// Map of device categories to their module dictionaries
const allModules: Record<string, DeviceModules> = {
outlets: outletModules,
fans: fanModules,
bulbs: bulbModules,
switches: switchModules
};
// First try exact match in all modules
for (const [cat, modules] of Object.entries(allModules)) {
if (deviceType in modules) {
DeviceClass = modules[deviceType];
category = cat;
logger.debug(`Found exact match for ${deviceType} in ${cat} modules`);
break;
}
}
// If no exact match, try to find a base class
if (!DeviceClass) {
// Device type prefix mapping
const prefixMap: Record<string, DeviceCategory> = {
// Fans
'Core': 'fans',
'LAP': 'fans',
'LTF': 'fans',
'Classic': 'fans',
'Dual': 'fans',
'LUH': 'fans',
'LEH': 'fans',
'LV-PUR': 'fans',
'LV-RH': 'fans',
// Outlets
'wifi-switch': 'outlets',
'ESW03': 'outlets',
'ESW01': 'outlets',
'ESW10': 'outlets',
'ESW15': 'outlets',
'ESO': 'outlets',
// Switches
'ESWL': 'switches',
'ESWD': 'switches',
// Bulbs
'ESL': 'bulbs',
'XYD': 'bulbs'
};
// Find category based on device type prefix
for (const [prefix, cat] of Object.entries(prefixMap)) {
if (deviceType.startsWith(prefix)) {
category = cat;
logger.debug(`Device type ${deviceType} matched prefix ${prefix} -> category ${cat}`);
// Try to find a base class in this category's modules
const modules = allModules[cat];
for (const [moduleType, ModuleClass] of Object.entries(modules)) {
const baseType = moduleType.split('-')[0]; // e.g., ESL100 from ESL100-USA
if (deviceType.startsWith(baseType)) {
DeviceClass = ModuleClass;
logger.debug(`Found base type match: ${deviceType} -> ${baseType}`);
break;
}
}
break;
}
}
}
if (DeviceClass) {
try {
// Add category to device details
details.deviceCategory = category;
// Handle outdoor plug sub-devices
if (deviceType === 'ESO15-TB' && details.subDeviceNo) {
const devices: [string, VeSyncBaseDevice | null][] = [];
// Create a device instance for each sub-device
const subDeviceDetails = {
...details,
deviceName: details.deviceName,
deviceStatus: details.deviceStatus,
subDeviceNo: details.subDeviceNo,
isSubDevice: true
};
const device = new DeviceClass(subDeviceDetails, manager);
devices.push([category, device]);
// Return array of sub-devices
return devices[0]; // Return first device, manager will handle adding all devices
} else {
const device = new DeviceClass(details, manager);
return [category, device];
}
} catch (error) {
logger.error(`Error creating device instance for ${deviceType}:`, error);
return [category, null];
}
} else {
logger.debug(`No device class found for type: ${deviceType}`);
return [category, null];
}
}
/**
* VeSync Manager Class
*/
export class VeSync {
private _debug: boolean;
private _redact: boolean;
private _energyUpdateInterval: number;
private _energyCheck: boolean;
private _devList: Record<string, VeSyncBaseDevice[]>;
private _lastUpdateTs: number | null;
private _inProcess: boolean;
private _excludeConfig: ExcludeConfig | null;
username: string;
password: string;
token: string | null;
accountId: string | null;
countryCode: string | null;
devices: VeSyncBaseDevice[] | null;
enabled: boolean;
updateInterval: number;
timeZone: string;
fans: VeSyncBaseDevice[];
outlets: VeSyncBaseDevice[];
switches: VeSyncBaseDevice[];
bulbs: VeSyncBaseDevice[];
scales: VeSyncBaseDevice[];
/**
* Initialize VeSync Manager
* @param username - VeSync account username
* @param password - VeSync account password
* @param timeZone - Optional timezone for device operations (defaults to America/New_York)
* @param debug - Optional debug mode flag
* @param redact - Optional redact mode flag
* @param apiUrl - Optional API base URL override
* @param customLogger - Optional custom logger implementation
* @param excludeConfig - Optional device exclusion configuration
*/
constructor(
username: string,
password: string,
timeZone: string = DEFAULT_TZ,
debug = false,
redact = true,
apiUrl?: string,
customLogger?: Logger,
excludeConfig?: ExcludeConfig
) {
this._debug = debug;
this._redact = redact;
this._energyUpdateInterval = DEFAULT_ENERGY_UPDATE_INTERVAL;
this._energyCheck = true;
this._lastUpdateTs = null;
this._inProcess = false;
this._excludeConfig = excludeConfig || null;
this.username = username;
this.password = password;
this.token = null;
this.accountId = null;
this.countryCode = null;
this.devices = null;
this.enabled = false;
this.updateInterval = API_RATE_LIMIT;
this.fans = [];
this.outlets = [];
this.switches = [];
this.bulbs = [];
this.scales = [];
this._devList = {
fans: this.fans,
outlets: this.outlets,
switches: this.switches,
bulbs: this.bulbs
};
// Set timezone first
if (typeof timeZone === 'string' && timeZone) {
const regTest = /[^a-zA-Z/_]/;
if (regTest.test(timeZone)) {
this.timeZone = DEFAULT_TZ;
logger.debug('Invalid characters in time zone - ', timeZone);
} else {
this.timeZone = timeZone;
}
} else {
this.timeZone = DEFAULT_TZ;
logger.debug('Time zone is not a string');
}
// Set custom API URL if provided, otherwise use default US endpoint
if (apiUrl) {
setApiBaseUrl(apiUrl);
} else {
// Always use US endpoint
setApiBaseUrl('https://smartapi.vesync.com');
}
// Set custom logger if provided
if (customLogger) {
setLogger(customLogger);
}
if (debug) {
this.debug = true;
}
if (redact) {
this.redact = true;
}
}
/**
* Get/Set debug mode
*/
get debug(): boolean {
return this._debug;
}
set debug(flag: boolean) {
this._debug = flag;
}
/**
* Get/Set redact mode
*/
get redact(): boolean {
return this._redact;
}
set redact(flag: boolean) {
this._redact = flag;
Helpers.shouldRedact = flag;
}
/**
* Get/Set energy update interval
*/
get energyUpdateInterval(): number {
return this._energyUpdateInterval;
}
set energyUpdateInterval(interval: number) {
if (interval > 0) {
this._energyUpdateInterval = interval;
}
}
/**
* Test if device should be removed
*/
static removeDevTest(device: VeSyncBaseDevice, newList: any[]): boolean {
if (Array.isArray(newList) && device.cid) {
for (const item of newList) {
if ('cid' in item && device.cid === item.cid) {
return true;
}
}
logger.debug(`Device removed - ${device.deviceName} - ${device.deviceType}`);
return false;
}
return true;
}
/**
* Test if new device should be added
*/
addDevTest(newDev: Record<string, any>): boolean {
if ('cid' in newDev) {
for (const devices of Object.values(this._devList)) {
for (const dev of devices) {
if (dev.cid === newDev.cid) {
return false;
}
}
}
}
return true;
}
/**
* Remove devices not found in device list return
*/
removeOldDevices(devices: any[]): boolean {
for (const [key, deviceList] of Object.entries(this._devList)) {
const before = deviceList.length;
this._devList[key] = deviceList.filter(device => VeSync.removeDevTest(device, devices));
const after = this._devList[key].length;
if (before !== after) {
logger.debug(`${before - after} ${key} removed`);
}
}
return true;
}
/**
* Correct devices without cid or uuid
*/
static setDevId(devices: any[]): any[] {
const devRem: number[] = [];
devices.forEach((dev, index) => {
if (!dev.cid) {
if (dev.macID) {
dev.cid = dev.macID;
} else if (dev.uuid) {
dev.cid = dev.uuid;
} else {
devRem.push(index);
logger.warn(`Device with no ID - ${dev.deviceName || ''}`);
}
}
});
if (devRem.length > 0) {
return devices.filter((_, index) => !devRem.includes(index));
}
return devices;
}
/**
* Process devices from API response
*/
private processDevices(deviceList: any[]): boolean {
try {
// Clear existing devices
for (const category of Object.keys(this._devList)) {
this._devList[category].length = 0;
}
if (!deviceList || deviceList.length === 0) {
logger.warn('No devices found in API response');
return false;
}
// Process each device
deviceList.forEach(dev => {
const [category, device] = objectFactory(dev, this);
// Handle outdoor plug sub-devices
if (dev.deviceType === 'ESO15-TB' && dev.subDeviceNo) {
const subDeviceDetails = {
...dev,
deviceName: dev.deviceName,
deviceStatus: dev.deviceStatus,
subDeviceNo: dev.subDeviceNo,
isSubDevice: true,
};
const [subCategory, subDevice] = objectFactory(subDeviceDetails, this);
if (subDevice && subCategory in this._devList) {
this._devList[subCategory].push(subDevice);
}
} else if (device && category in this._devList) {
this._devList[category].push(device);
}
});
// Update device list reference
this.devices = Object.values(this._devList).flat();
// Return true if we processed at least one device successfully
return this.devices.length > 0;
} catch (error) {
logger.error('Error processing devices:', error);
return false;
}
}
/**
* Get list of VeSync devices
*/
async getDevices(): Promise<boolean> {
if (!this.enabled) {
logger.error('Not logged in to VeSync');
return false;
}
this._inProcess = true;
let success = false;
try {
const [response] = await Helpers.callApi(
'/cloud/v2/deviceManaged/devices',
'post',
Helpers.reqBody(this, 'devicelist'),
Helpers.reqHeaders(this),
this
);
if (!response) {
logger.error('No response received from VeSync API');
return false;
}
if (response.error) {
logger.error('API error:', response.msg || 'Unknown error');
return false;
}
if (!response.result?.list) {
logger.error('No device list found in response');
return false;
}
const deviceList = response.result.list;
success = this.processDevices(deviceList);
if (success) {
// Log device discovery results
logger.debug('\n=== Device Discovery Summary ===');
logger.debug(`Total devices processed: ${deviceList.length}`);
// Log device types found
const deviceTypes = deviceList.map((d: Record<string, any>) => d.deviceType);
logger.debug('\nDevice types found:', deviceTypes);
// Log devices by category with details
logger.debug('\nDevices by Category:');
logger.debug('---------------------');
for (const [category, devices] of Object.entries(this._devList)) {
if (devices.length > 0) {
logger.debug(`\n${category.toUpperCase()} (${devices.length} devices):`);
devices.forEach((d: VeSyncBaseDevice) => {
logger.debug(` • ${d.deviceName}`);
logger.debug(` Type: ${d.deviceType}`);
logger.debug(` Status: ${d.deviceStatus}`);
logger.debug(` ID: ${d.cid}`);
});
}
}
// Log summary statistics
logger.debug('\nSummary Statistics:');
logger.debug('-------------------');
logger.debug(`Total Devices: ${this.devices?.length || 0}`);
for (const [category, devices] of Object.entries(this._devList)) {
logger.debug(`${category}: ${devices.length} devices`);
}
logger.debug('\n=== End of Device Discovery ===\n');
}
} catch (err) {
const error = err as { code?: string; message?: string };
if (error.code === 'ECONNABORTED') {
logger.error('VeSync API request timed out');
} else if (error.code === 'ECONNREFUSED') {
logger.error('Unable to connect to VeSync API');
} else {
logger.error('Error getting device list:', error.message || 'Unknown error');
}
}
this._inProcess = false;
return success;
}
/**
* Login to VeSync server
*/
async login(retryAttempts: number = 3, initialDelayMs: number = 1000): Promise<boolean> {
const body = Helpers.reqBody(this, 'login');
for (let attempt = 0; attempt < retryAttempts; attempt++) {
try {
logger.debug('Login attempt', {
attempt: attempt + 1,
apiUrl: getApiBaseUrl(),
timeZone: this.timeZone,
appVersion: body.appVersion
});
const [response, status] = await Helpers.callApi(
'/cloud/v1/user/login',
'post',
body,
{},
this
);
logger.debug('Login response:', { status, response });
// Handle specific error codes
if (response && response.code) {
switch (response.code) {
case -11012022:
logger.error('App version too low error. Current version:', body.appVersion);
logger.error('This typically indicates an API version compatibility issue.');
break;
case -11003:
logger.error('Authentication failed - check credentials');
break;
case -11001:
logger.error('Invalid request format');
break;
default:
logger.error('API error code:', response.code, 'message:', response.msg);
}
}
if (response?.result?.token) {
this.token = response.result.token;
this.accountId = response.result.accountID;
this.countryCode = response.result.countryCode;
this.enabled = true;
logger.debug('Login successful for region:', this.countryCode);
return true;
}
// If we reach here, login failed but didn't throw an error
const delay = initialDelayMs * Math.pow(2, attempt);
logger.debug(`Login attempt ${attempt + 1} failed, retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
} catch (error) {
if (attempt === retryAttempts - 1) {
logger.error('Login error after all retry attempts:', error);
return false;
}
const delay = initialDelayMs * Math.pow(2, attempt);
logger.debug(`Login attempt ${attempt + 1} failed with error, retrying in ${delay}ms...`, error);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
logger.error('Unable to login with supplied credentials after all retry attempts');
return false;
}
/**
* Test if update interval has been exceeded
*/
deviceTimeCheck(): boolean {
return (
this._lastUpdateTs === null ||
(Date.now() - this._lastUpdateTs) / 1000 > this.updateInterval
);
}
/**
* Check if a device should be excluded based on configuration
*/
private shouldExcludeDevice(device: VeSyncBaseDevice): boolean {
if (!this._excludeConfig) {
return false;
}
const exclude = this._excludeConfig;
// Check device type
if (exclude.type?.includes(device.deviceType.toLowerCase())) {
logger.debug(`Excluding device ${device.deviceName} by type: ${device.deviceType}`);
return true;
}
// Check device model
if (exclude.model?.some(model => device.deviceType.toUpperCase().includes(model.toUpperCase()))) {
logger.debug(`Excluding device ${device.deviceName} by model: ${device.deviceType}`);
return true;
}
// Check exact name match
if (exclude.name?.includes(device.deviceName.trim())) {
logger.debug(`Excluding device ${device.deviceName} by exact name match`);
return true;
}
// Check name patterns
if (exclude.namePattern) {
for (const pattern of exclude.namePattern) {
try {
const regex = new RegExp(pattern);
if (regex.test(device.deviceName.trim())) {
logger.debug(`Excluding device ${device.deviceName} by name pattern: ${pattern}`);
return true;
}
} catch (error) {
logger.warn(`Invalid regex pattern in exclude config: ${pattern}`);
}
}
}
// Check device ID (cid or uuid)
if (exclude.id?.includes(device.cid) || exclude.id?.includes(device.uuid)) {
logger.debug(`Excluding device ${device.deviceName} by ID: ${device.cid}/${device.uuid}`);
return true;
}
return false;
}
/**
* Update device list and details
*/
async update(): Promise<void> {
if (this.deviceTimeCheck()) {
if (!this.enabled) {
logger.error('Not logged in to VeSync');
return;
}
await this.getDevices();
logger.debug('Start updating the device details one by one');
for (const deviceList of Object.values(this._devList)) {
for (const device of deviceList) {
try {
if (!this.shouldExcludeDevice(device)) {
await device.getDetails();
} else {
logger.debug(`Skipping details update for excluded device: ${device.deviceName}`);
}
} catch (error) {
logger.error(`Error updating ${device.deviceName}:`, error);
}
}
}
this._lastUpdateTs = Date.now();
}
}
/**
* Create device instance from details
*/
createDevice(details: Record<string, any>): VeSyncBaseDevice | null {
const deviceType = details.deviceType;
const deviceClass = fanModules[deviceType];
if (deviceClass) {
return new deviceClass(details, this);
}
return null;
}
/**
* Call API with authentication
*/
protected async callApi(
endpoint: string,
method: string,
data: any = null,
headers: Record<string, string> = {}
): Promise<[any, number]> {
return await Helpers.callApi(endpoint, method, data, headers, this);
}
}