@viguza/homebridge-ezviz
Version:
A short description about what your plugin does.
462 lines • 18.1 kB
JavaScript
import axios from 'axios';
import querystring from 'querystring';
import crypto, { randomBytes } from 'crypto';
import { EZVIZ_CLIENT_TYPE, EZVIZ_USER_AGENT, EZVIZ_BASE_API_URL, EZVIZ_DOMAINS_ENDPOINT, EZVIZ_AUTH_ENDPOINT, EZVIZ_DEVICES_ENDPOINT, EZVIZ_SWITCH_STATUS_ENDPOINT, EZVIZ_UNIFIEDMSG_ENDPOINT, EZVIZ_SERVER_INFO_ENDPOINT, EZVIZ_DEFENCE_MODE_ENDPOINT, EZVIZ_DEFENCE_MODE_GET_ENDPOINT, API_ENDPOINT_REFRESH, RUSSIA_DOMAIN, RUSSIA_AREA_ID, DEFAULT_GROUP_ID, } from './ezviz-constants.js';
import { DefenceMode } from '../utils/enums.js';
import { sendRequest } from './ezviz-requests.js';
/**
* EZVIZ API client for interacting with EZVIZ services
*/
export class EZVIZAPI {
config;
sessionId;
log;
constructor(config, log) {
this.config = config;
this.sessionId = null;
this.log = log;
}
/**
* Generates a random string of specified length
* @param length - The length of the string to generate
* @returns Random string
*/
randomStr(length) {
return randomBytes(length)
.toString('base64')
.slice(0, length)
.replace(/\+/g, '0')
.replace(/\//g, '0');
}
/**
* Authenticates with the EZVIZ API
* @returns Promise resolving to credentials or undefined if authentication fails
*/
async authenticate() {
if (!this.config.email || !this.config.password) {
this.log?.error('Email and password are required for authentication');
return;
}
const emailHash = crypto.createHash('md5').update(this.config.email).digest('hex');
const passHash = crypto.createHash('md5').update(this.config.password).digest('hex');
const data = querystring.stringify({
account: this.config.email,
featureCode: emailHash,
password: passHash,
});
const config = {
method: 'post',
url: `${this.config.domain}${EZVIZ_AUTH_ENDPOINT}`,
headers: {
'content-type': 'application/x-www-form-urlencoded',
'clienttype': EZVIZ_CLIENT_TYPE,
'user-agent': EZVIZ_USER_AGENT,
},
data,
};
try {
const response = await axios(config);
const auth = response.data;
if (auth.retcode) {
this.log?.error(`Login error: ${auth.retcode}`);
return;
}
if (auth.meta?.code === 6002) {
this.log?.error('2 Factor Authentication accounts are not supported at this time.');
return;
}
if (auth.meta?.code !== 200) {
this.log?.error('Login error code:', auth.meta?.code);
return;
}
if (auth.loginSession?.sessionId) {
const login = auth;
const credentials = {
sessionId: login.loginSession.sessionId,
rfSessionId: login.loginSession.rfSessionId,
featureCode: emailHash,
cuName: this.randomStr(24),
username: login.loginUser?.username,
};
this.sessionId = login.loginSession.sessionId;
this.config.credentials = credentials;
return credentials;
}
else {
this.log?.error('No sessionId found in login response');
return;
}
}
catch (error) {
this.log?.error('Unable to login:', error);
throw error;
}
}
/**
* Refreshes the session using the refresh token, falling back to full re-authentication
* if the refresh token is missing or rejected.
* @returns Promise resolving to updated credentials or undefined on failure
*/
async refreshSession() {
const creds = this.config.credentials;
if (!creds?.rfSessionId) {
this.log?.debug('No refresh token available, falling back to full re-authentication');
return this.authenticate();
}
const data = querystring.stringify({
cuName: creds.cuName,
featureCode: creds.featureCode,
refreshSessionId: creds.rfSessionId,
});
const config = {
method: 'put',
url: `${this.config.domain}${API_ENDPOINT_REFRESH}`,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'clientType': EZVIZ_CLIENT_TYPE,
'User-Agent': EZVIZ_USER_AGENT,
'sessionId': this.sessionId ?? '',
},
data,
};
try {
const response = await axios(config);
const result = response.data;
if (result.meta?.code !== 200) {
this.log?.debug(`Session refresh rejected (code ${result.meta?.code}), falling back to full re-authentication`);
return this.authenticate();
}
const updated = {
sessionId: result.sessionInfo.sessionId,
rfSessionId: result.sessionInfo.refreshSessionId,
featureCode: creds.featureCode,
cuName: creds.cuName,
};
this.sessionId = updated.sessionId;
this.config.credentials = updated;
this.log?.debug('Session refreshed successfully');
return updated;
}
catch (error) {
this.log?.debug('Session refresh failed, falling back to full re-authentication:', error);
return this.authenticate();
}
}
/**
* Gets the domain URL for the specified region
* @param id - The region ID
* @returns Promise resolving to the domain URL
*/
async getDomain(id) {
if (id === RUSSIA_AREA_ID) {
return `https://${RUSSIA_DOMAIN}`;
}
const headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'clientType': EZVIZ_CLIENT_TYPE,
'User-Agent': EZVIZ_USER_AGENT,
};
const domainReq = {
headers: headers,
method: 'POST',
url: `${EZVIZ_BASE_API_URL}${EZVIZ_DOMAINS_ENDPOINT}`,
data: querystring.stringify({
areaId: id,
}),
};
try {
const response = await axios(domainReq);
const domain = response.data;
if (!domain.domain) {
throw new Error('Invalid domain response from API');
}
return `https://${domain.domain}`;
}
catch (error) {
this.log?.error('Error fetching domain:', error);
throw error;
}
}
/**
* Returns the MQTT push address from the server info endpoint.
* Used to connect the MQTT client for real-time push notifications.
*/
async getServiceUrls() {
try {
const response = await sendRequest(this.config, this.config.domain, EZVIZ_SERVER_INFO_ENDPOINT, 'GET');
const sysConfig = response?.systemConfigInfo;
const pushAddr = sysConfig?.pushAddr ?? null;
if (pushAddr && this.config.credentials) {
this.config.credentials.pushAddr = pushAddr;
}
return pushAddr;
}
catch (error) {
this.log?.debug('Could not fetch service URLs:', error);
return null;
}
}
/**
* Lists all devices for the authenticated user
* @returns Promise resolving to device list or undefined if failed
*/
async listDevices() {
if (!this.sessionId) {
try {
await this.authenticate();
}
catch (error) {
this.log?.error('Failed to authenticate before listing devices:', error);
return;
}
}
try {
const query = querystring.stringify({
filter: 'CONNECTION,WIFI,SWITCH,STATUS,NODISTURB,P2P,FEATURE,DETECTOR',
groupId: DEFAULT_GROUP_ID,
limit: 30,
offset: 0,
});
const info = await sendRequest(this.config, this.config.domain, `${EZVIZ_DEVICES_ENDPOINT}?${query}`, 'GET');
return info;
}
catch (error) {
this.log?.error('Error fetching devices:', error);
throw error;
}
}
/**
* Returns the timestamp (ms) of the most recent alarm for a device, or null if none.
* Fetches up to 10 recent messages and filters client-side — the API ignores the
* deviceSerials query param and always returns global results.
* The EZVIZ API may return the epoch in seconds or milliseconds; values > 1e10 are ms.
*/
async getLastAlarmTime(serialNumber) {
if (!this.sessionId) {
try {
await this.authenticate();
}
catch (error) {
this.log?.error('Failed to authenticate before fetching alarm time:', error);
throw error;
}
}
try {
const query = querystring.stringify({
deviceSerials: serialNumber,
limit: 10,
stype: '92',
});
const response = await sendRequest(this.config, this.config.domain, `${EZVIZ_UNIFIEDMSG_ENDPOINT}?${query}`, 'GET');
const messages = response?.message ?? response?.messages ?? [];
this.log?.debug(`getLastAlarmTime(${serialNumber}): ${messages.length} message(s), first deviceSerial=${messages[0]?.deviceSerial ?? 'none'}`);
const latest = messages.find(m => m.deviceSerial === serialNumber);
if (!latest?.time) {
this.log?.debug(`getLastAlarmTime(${serialNumber}): no matching message found`);
return null;
}
this.log?.debug(`getLastAlarmTime(${serialNumber}): raw time=${JSON.stringify(latest.time)}`);
const ts = typeof latest.time === 'string' ? parseFloat(latest.time) : latest.time;
const result = isNaN(ts) ? null : (ts > 1e10 ? ts : ts * 1000);
this.log?.debug(`getLastAlarmTime(${serialNumber}): resolved alarmTime=${result}`);
return result;
}
catch (error) {
this.log?.error('Error fetching last alarm time:', error);
throw error;
}
}
/**
* Sets the state of a switch/plug
* @param serialNumber - The device serial number
* @param type - The switch type
* @param value - The value to set (true/false)
*/
async setSwitchState(serialNumber, type, value) {
if (!serialNumber) {
throw new Error('Serial number is required');
}
if (!this.sessionId) {
try {
await this.authenticate();
}
catch (error) {
this.log?.error('Failed to authenticate before setting switch state:', error);
throw error;
}
}
const config = {
method: 'post',
url: `${this.config.domain}${EZVIZ_SWITCH_STATUS_ENDPOINT}`,
headers: {
'sessionid': this.sessionId,
'clienttype': EZVIZ_CLIENT_TYPE,
'user-agent': EZVIZ_USER_AGENT,
},
data: querystring.stringify({
channel: 0,
clientType: 1,
enable: value ? 1 : 0,
serial: serialNumber,
type: type,
}),
};
try {
const response = await axios(config);
if (response.data?.retcode) {
throw new Error(`Switch state update failed: ${response.data.retcode}`);
}
return response.data;
}
catch (error) {
this.log?.error('Error setting switch state:', error);
throw error;
}
}
/**
* Gets the current state of a switch/plug
* @param serialNumber - The device serial number
* @param type - The switch type
* @returns Promise resolving to the switch state
*/
async getSwitchState(serialNumber, type) {
if (!serialNumber) {
throw new Error('Serial number is required');
}
if (!this.sessionId) {
try {
await this.authenticate();
}
catch (error) {
this.log?.error('Failed to authenticate before getting switch state:', error);
throw error;
}
}
const deviceList = await this.listDevices();
if (!deviceList) {
const message = 'No devices found';
this.log?.debug(message);
throw new Error(message);
}
const deviceInfo = deviceList.deviceInfos?.find((device) => device.deviceSerial === serialNumber);
if (!deviceInfo) {
const message = `Device with serial ${serialNumber} was not found`;
this.log?.debug(message);
throw new Error(message);
}
if (deviceInfo?.status !== 1) {
const message = `Device with serial ${serialNumber} is offline`;
this.log?.debug(message);
throw new Error(message);
}
const deviceSwitch = deviceList.SWITCH?.[serialNumber]?.find((device) => device.type === type);
if (!deviceSwitch) {
const message = `Switch for device serial ${serialNumber} was not found`;
this.log?.debug(message);
throw new Error(message);
}
return deviceSwitch?.enable;
}
/**
* Sets the defence mode (alarm mode) for a group
* @param groupId - The group ID (default: 1)
* @param mode - The defence mode (DefenceMode enum value)
* @returns Promise resolving when defence mode is set
*/
async setDefenceMode(groupId = DEFAULT_GROUP_ID, mode) {
if (!Object.values(DefenceMode).includes(mode)) {
throw new Error(`Invalid defence mode. Must be one of: ${Object.values(DefenceMode).join(', ')}`);
}
if (!this.sessionId) {
try {
await this.authenticate();
}
catch (error) {
this.log?.error('Failed to authenticate before setting defence mode:', error);
throw error;
}
}
const query = querystring.stringify({
groupId: groupId,
mode: mode,
});
const config = {
method: 'post',
url: `${this.config.domain}${EZVIZ_DEFENCE_MODE_ENDPOINT}?${query}`,
headers: {
'sessionid': this.sessionId,
'clienttype': EZVIZ_CLIENT_TYPE,
'user-agent': EZVIZ_USER_AGENT,
},
};
try {
const response = await axios(config);
if (response.data?.retcode && response.data.retcode !== '200') {
throw new Error(`Defence mode update failed: ${response.data.retcode}`);
}
if (response.data?.meta?.code && response.data.meta.code !== 200) {
throw new Error(`Defence mode update failed: ${response.data.meta.code} - ${response.data.meta.message}`);
}
return response.data;
}
catch (error) {
this.log?.error('Error setting defence mode:', error);
throw error;
}
}
/**
* Gets the current defence mode (alarm mode) for a group
* @param groupId - The group ID (default: 1)
* @returns Promise resolving to the current defence mode (DefenceMode enum value)
*/
async getDefenceMode(groupId = DEFAULT_GROUP_ID) {
if (!this.sessionId) {
try {
await this.authenticate();
}
catch (error) {
this.log?.error('Failed to authenticate before getting defence mode:', error);
throw error;
}
}
const query = querystring.stringify({
groupId: groupId,
});
const config = {
method: 'get',
url: `${this.config.domain}${EZVIZ_DEFENCE_MODE_GET_ENDPOINT}?${query}`,
headers: {
'sessionid': this.sessionId,
'clienttype': EZVIZ_CLIENT_TYPE,
'user-agent': EZVIZ_USER_AGENT,
},
};
try {
const response = await axios(config);
if (response.data?.retcode && response.data.retcode !== '200') {
throw new Error(`Failed to get defence mode: ${response.data.retcode}`);
}
if (response.data?.meta?.code && response.data.meta.code !== 200) {
throw new Error(`Failed to get defence mode: ${response.data.meta.code} - ${response.data.meta.message}`);
}
// Extract the mode from the response
// The response structure may vary, but typically it's in response.data.mode or response.data.defenceMode
const mode = response.data?.mode || response.data?.defenceMode || response.data?.data?.mode;
if (mode === undefined || mode === null) {
this.log?.debug('No mode found in response, defaulting to UNSET_MODE');
return DefenceMode.UNSET_MODE;
}
const modeValue = typeof mode === 'string' ? parseInt(mode, 10) : mode;
if (!Object.values(DefenceMode).includes(modeValue)) {
this.log?.debug(`Unknown defence mode value: ${modeValue}, defaulting to UNSET_MODE`);
return DefenceMode.UNSET_MODE;
}
return modeValue;
}
catch (error) {
this.log?.error('Error getting defence mode:', error);
throw error;
}
}
}
//# sourceMappingURL=ezviz-api.js.map