UNPKG

iobroker.acinfinity

Version:
396 lines (346 loc) 13.4 kB
/** * AC Infinity API Client * Handles all API communication with AC Infinity servers */ 'use strict'; const axios = require('axios'); const { API_BASE_URL, API_ENDPOINTS } = require('./constants'); class ACInfinityClient { /** * Creates a new AC Infinity API client * * @param {string} email - AC Infinity account email * @param {string} password - AC Infinity account password * @param {object} log - Logger object */ constructor(email, password, log) { this.email = email; this.password = password; this.log = log; this.token = null; this.axiosInstance = axios.create({ timeout: 30000, validateStatus: status => status >= 200 && status < 300, }); } /** * Creates headers for API requests * * @param {boolean} useAuthToken - Whether to include authentication token * @returns {object} */ createHeaders(useAuthToken = false) { const headers = { 'User-Agent': 'ACController/1.8.5 (com.acinfinity.humiture; build:500; iOS 17.0.1) Alamofire/5.7.1', 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', }; if (useAuthToken && this.token) { headers.token = this.token; } return headers; } /** * Performs API login and obtains authentication token * * @returns {Promise<void>} */ async login() { try { const normalizedPassword = this.password.substring(0, 25); const formData = `appEmail=${encodeURIComponent(this.email)}&appPasswordl=${encodeURIComponent(normalizedPassword)}`; this.log.debug(`Login attempt with: ${this.email}`); const response = await this.axiosInstance.post(`${API_BASE_URL}${API_ENDPOINTS.LOGIN}`, formData, { headers: this.createHeaders(false), }); if (response.data && response.data.code === 200) { this.token = response.data.data.appId; this.log.debug('Login successful'); return; } throw new Error(`Login failed: ${response.data ? response.data.msg : 'Unknown error'}`); } catch (error) { if (error.response) { throw new Error( `Login failed with status ${error.response.status}: ${error.response.data ? error.response.data.msg : 'Unknown error'}`, ); } throw new Error(`Login failed: ${error.message}`); } } /** * Checks if client is logged in * * @returns {boolean} */ isLoggedIn() { return !!this.token; } /** * General API call with automatic re-login on 401 * * @param {string} endpoint * @param {object} data * @param {boolean} needsAuth * @returns {Promise<any>} */ async apiCall(endpoint, data, needsAuth = true) { if (needsAuth && !this.isLoggedIn()) { await this.login(); } try { const formData = Object.entries(data) .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) .join('&'); const response = await this.axiosInstance.post(`${API_BASE_URL}${endpoint}`, formData, { headers: this.createHeaders(needsAuth), }); if (response.data && response.data.code === 200) { return response.data.data || {}; } throw new Error(`API call to ${endpoint} failed: ${response.data ? response.data.msg : 'Unknown error'}`); } catch (error) { if (error.response && error.response.status === 401 && needsAuth) { this.log.info('Token expired, attempting to re-login'); await this.login(); return this.apiCall(endpoint, data, needsAuth); } if (error.response) { throw new Error(`API call to ${endpoint} failed with status ${error.response.status}`); } throw new Error(`API call to ${endpoint} failed: ${error.message}`); } } /** * Gets list of all AC Infinity devices * * @returns {Promise<Array>} */ async getDevicesList() { return this.apiCall(API_ENDPOINTS.DEVICE_LIST, { userId: this.token }, true); } /** * Gets mode settings for a specific device port * * @param {string|number} deviceId * @param {number} portId * @returns {Promise<object>} */ async getDeviceModeSettings(deviceId, portId) { this.log.debug(`Getting mode settings for deviceId=${deviceId}, portId=${portId}`); return this.apiCall( API_ENDPOINTS.DEVICE_MODE_SETTINGS, { devId: deviceId, port: portId, }, true, ); } /** * Gets advanced settings for a device or port * * @param {string|number} deviceId * @param {number} portId - 0 for controller-level settings * @returns {Promise<object>} */ async getDeviceSettings(deviceId, portId) { this.log.debug(`Getting device settings for deviceId=${deviceId}, portId=${portId}`); return this.apiCall( API_ENDPOINTS.DEVICE_SETTINGS, { devId: deviceId, port: portId, }, true, ); } /** * Sends a raw URL-encoded form payload to the addDevMode endpoint. * This is the single write path for all mode and speed changes. * * @param {string} formData - URL-encoded form data string * @returns {Promise<boolean>} */ async sendRawModeUpdate(formData) { if (!this.isLoggedIn()) { await this.login(); } try { this.log.debug(`Sending mode update payload`); const response = await this.axiosInstance.post( `${API_BASE_URL}${API_ENDPOINTS.UPDATE_DEVICE_MODE}`, formData, { headers: this.createHeaders(true) }, ); if (response.data && response.data.code === 200) { this.log.debug('Mode update successful'); return true; } throw new Error(`Mode update failed: ${response.data ? response.data.msg : 'Unknown error'}`); } catch (error) { if (error.response && error.response.status === 401) { this.log.info('Token expired, re-logging in'); await this.login(); return this.sendRawModeUpdate(formData); } this.log.error(`Mode update error: ${error.message}`); throw error; } } /** * Sets the device mode and speeds for a port. * Uses a minimal payload that mirrors the working app requests. * * atType values: * 1=Off, 2=On, 3=Auto, 4=Timer to On, 5=Timer to Off, 6=Cycle, 7=Schedule, 8=VPD * * @param {string|number} deviceId * @param {number} portId * @param {number} atType - Mode type (1-8) * @param {number} onSpeed - On speed (0-10) * @param {number} offSpeed - Off speed (0-10) * @returns {Promise<boolean>} */ async setDeviceMode(deviceId, portId, atType, onSpeed, offSpeed) { if (!this.isLoggedIn()) { await this.login(); } const settings = await this.getDeviceModeSettings(deviceId, portId); const modeSetid = settings.modeSetid; const vpdnums = settings.vpdnums !== undefined && settings.vpdnums !== null ? settings.vpdnums : 0; // speak = active speed; 0 when mode is Off (atType=1) const speak = atType === 2 ? onSpeed : 0; const formData = [ `modeSetid=${encodeURIComponent(modeSetid)}`, `devId=${encodeURIComponent(deviceId)}`, `externalPort=${encodeURIComponent(portId)}`, `offSpead=${encodeURIComponent(offSpeed)}`, `onSpead=${encodeURIComponent(onSpeed)}`, `atType=${encodeURIComponent(atType)}`, `speak=${encodeURIComponent(speak)}`, `curMode=${encodeURIComponent(atType)}`, `vpdstatus=0`, `vpdnums=${encodeURIComponent(vpdnums)}`, ].join('&'); this.log.info( `setDeviceMode: deviceId=${deviceId}, portId=${portId}, atType=${atType}, onSpeed=${onSpeed}, offSpeed=${offSpeed}`, ); return this.sendRawModeUpdate(formData); } /** * Updates specific mode settings parameters (timer/cycle/auto/vpd/schedule values). * Fetches current settings, applies overrides, and sends the full payload. * * @param {string|number} deviceId * @param {number} portId * @param {Array<Array<string|number>>} keyValues - Array of [key, value] pairs to override * @returns {Promise<boolean>} */ async updateDeviceModeSettings(deviceId, portId, keyValues) { if (!this.isLoggedIn()) { await this.login(); } const deviceIdStr = String(deviceId); const portIdNum = Number(portId); const settings = await this.getDeviceModeSettings(deviceIdStr, portIdNum); this.log.debug(`Current mode settings fetched for update`); // Remove fields that the API rejects in write calls const fieldsToRemove = ['devMacAddr', 'ipcSetting', 'devSetting']; for (const field of fieldsToRemove) { delete settings[field]; } // Ensure required fields exist if (!('vpdstatus' in settings)) { settings.vpdstatus = 0; } if (!('vpdnums' in settings)) { settings.vpdnums = 0; } settings.devId = parseInt(deviceIdStr); if ('modeSetid' in settings) { settings.modeSetid = String(settings.modeSetid); } // Apply the requested overrides for (const [key, value] of keyValues) { settings[key] = typeof value === 'number' ? value : parseInt(String(value)); } // Replace any remaining null/undefined with 0 for (const key in settings) { if (settings[key] === null || settings[key] === undefined) { settings[key] = 0; } } const formData = Object.entries(settings) .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) .join('&'); return this.sendRawModeUpdate(formData); } /** * Updates advanced (device/port-level) settings * * @param {string|number} deviceId * @param {number} portId - 0 for controller-level settings * @param {string} deviceName - Device name (prevents API from resetting it to "None") * @param {Array<Array<string|number>>} keyValues - Array of [key, value] pairs * @returns {Promise<void>} */ async updateAdvancedSettings(deviceId, portId, deviceName, keyValues) { if (!this.isLoggedIn()) { await this.login(); } const deviceIdStr = String(deviceId); const portIdNum = Number(portId); const settings = await this.getDeviceSettings(deviceIdStr, portIdNum); settings.devName = deviceName; const fieldsToRemove = [ 'setId', 'devMacAddr', 'portResistance', 'devTimeZone', 'sensorSetting', 'sensorTransBuff', 'subDeviceVersion', 'secFucReportTime', 'updateAllPort', 'calibrationTime', ]; for (const field of fieldsToRemove) { delete settings[field]; } const stringFields = ['sensorTransBuffStr', 'sensorSettingStr', 'portParamData', 'paramSensors']; for (const field of stringFields) { if (!(field in settings) || settings[field] === null) { settings[field] = ''; } } const defaultFields = ['sensorOneType', 'isShare', 'targetVpdSwitch', 'sensorTwoType', 'zoneSensorType']; for (const field of defaultFields) { if (!(field in settings)) { settings[field] = 0; } } settings.devId = parseInt(deviceIdStr); for (const key in settings) { if (settings[key] === null || settings[key] === undefined) { settings[key] = 0; } } for (const [key, value] of keyValues) { settings[key] = parseInt(String(value)); } const formData = Object.entries(settings) .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) .join('&'); const response = await this.axiosInstance.post( `${API_BASE_URL}${API_ENDPOINTS.UPDATE_ADVANCED_SETTINGS}`, formData, { headers: this.createHeaders(true) }, ); if (response.data && response.data.code === 200) { this.log.info(`Advanced settings updated for device ${deviceIdStr}, port ${portIdNum}`); return; } throw new Error(`Failed to update advanced settings: ${response.data ? response.data.msg : 'Unknown error'}`); } } module.exports = ACInfinityClient;