/** * AC Infinity API Client * Ported from TypeScript homebridge-acinfinity plugin */ const API_URL_LOGIN = '/api/user/appUserLogin'; const API_URL_GET_DEVICE_INFO_LIST_ALL = '/api/user/devInfoListAll'; export class ACInfinityClientError extends Error { constructor(message) { super(message); this.name = 'ACInfinityClientError'; } } export class ACInfinityClientCannotConnect extends ACInfinityClientError { constructor() { super('Cannot connect to AC Infinity API'); } } export class ACInfinityClientInvalidAuth extends ACInfinityClientError { constructor() { super('Invalid authentication credentials'); } } export class ACInfinityClient { constructor(host, email, password) { this.host = host; this.email = email; this.password = password; this.userId = null; } async login() { try { // AC Infinity API does not accept passwords greater than 25 characters - UPDATE: Reference impl uses full password? // const normalizedPassword = this.password.substring(0, 25); const normalizedPassword = this.password; const response = await fetch(`${this.host}${API_URL_LOGIN}`, { method: 'POST', headers: { 'User-Agent': 'ACController/1.9.7 (com.acinfinity.humiture; build:533; iOS 18.5.0) Alamofire/5.10.2', 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ appEmail: this.email, appPasswordl: normalizedPassword, // Note: intentional typo in API }), }); const data = await response.json(); if (data.code !== 200) { if (data.code === 10001) { throw new ACInfinityClientInvalidAuth(); } throw new ACInfinityClientError(`Login failed: ${JSON.stringify(data)}`); } this.userId = data.data.appId; console.log('[AC] Successfully logged in to AC Infinity API'); return this.userId; } catch (error) { console.error('[AC] Login error details:', error); // Added detailed logging if (error instanceof ACInfinityClientError) { throw error; } throw new ACInfinityClientCannotConnect(); } } isLoggedIn() { return this.userId !== null; } getAuthHeaders() { if (!this.userId) { throw new ACInfinityClientError('Client is not logged in'); } return { token: this.userId, phoneType: '1', appVersion: '1.9.7', }; } async getDevicesListAll() { if (!this.isLoggedIn()) { throw new ACInfinityClientError('AC Infinity client is not logged in'); } try { const response = await fetch(`${this.host}${API_URL_GET_DEVICE_INFO_LIST_ALL}`, { method: 'POST', headers: { 'User-Agent': 'ACController/1.9.7 (com.acinfinity.humiture; build:533; iOS 18.5.0) Alamofire/5.10.2', 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', ...this.getAuthHeaders(), }, body: new URLSearchParams({ userId: this.userId, }), }); const data = await response.json(); if (data.code !== 200) { throw new ACInfinityClientError(`Request failed: ${JSON.stringify(data)}`); } return data.data || []; } catch (error) { if (error instanceof ACInfinityClientError) { throw error; } throw new ACInfinityClientCannotConnect(); } } /** * Extract sensor readings from device list * @returns {Array} Array of {device, channel, value} objects */ async getSensorReadings() { const devices = await this.getDevicesListAll(); const readings = []; for (const device of devices) { const devId = device.devId; const devName = device.devName || `device-${devId}`; // Use deviceInfo if available (newer API structure), otherwise fallback to root/devSettings const info = device.deviceInfo || device; const settings = device.devSettings || info; // Normalize device name for use as identifier const deviceId = devName .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, ''); // --- Device Level Sensors --- // Temperature (Celsius * 100) if (info.temperature !== undefined) { readings.push({ device: deviceId, channel: 'temperature', value: info.temperature / 100, }); } else if (settings.temperature !== undefined) { readings.push({ device: deviceId, channel: 'temperature', value: settings.temperature / 100, }); } // Humidity (% * 100) if (info.humidity !== undefined) { readings.push({ device: deviceId, channel: 'humidity', value: info.humidity / 100, }); } else if (settings.humidity !== undefined) { readings.push({ device: deviceId, channel: 'humidity', value: settings.humidity / 100, }); } // VPD if (info.vpdnums !== undefined) { readings.push({ device: deviceId, channel: 'vpd', value: info.vpdnums / 100, }); } else if (settings.vpdnums !== undefined) { readings.push({ device: deviceId, channel: 'vpd', value: settings.vpdnums / 100, }); } // --- Port Level Sensors/State --- const ports = info.ports || device.devPortList; if (ports && Array.isArray(ports)) { for (const port of ports) { const portId = port.port || port.portId; const portName = port.portName || `port${portId}`; // Create a descriptive suffix for the port device, e.g. "wall-fan" or "wall-port1" // If portName is generic "Port X", use number. If it's specific "Fan", use that. const suffix = portName.toLowerCase().replace(/[^a-z0-9]+/g, '-'); const portDeviceId = `${deviceId}-${suffix}`; // Port specific sensors (if any - sometimes temp usually on device) if (port.temperature !== undefined) { readings.push({ device: portDeviceId, channel: 'temperature', value: port.temperature / 100, }); } if (port.humidity !== undefined) { readings.push({ device: portDeviceId, channel: 'humidity', value: port.humidity / 100, }); } // Level / Speed (speak) if (port.speak !== undefined) { readings.push({ device: portDeviceId, channel: 'level', value: port.speak, }); } } } } return readings; } } export default ACInfinityClient;