import 'dotenv/config'; import express from 'express'; import Database from 'better-sqlite3'; import path from 'path'; import { fileURLToPath } from 'url'; import webpack from 'webpack'; import webpackDevMiddleware from 'webpack-dev-middleware'; import config from './webpack.config.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const compiler = webpack(config); // --- CONFIGURATION --- const BASE_URL = 'http://www.acinfinityserver.com'; const USER_AGENT = 'ACController/1.9.7 (com.acinfinity.humiture; build:533; iOS 18.5.0) Alamofire/5.10.2'; const POLL_INTERVAL_MS = 60000; // 60 seconds const DB_FILE = 'ac_data.db'; const PORT = 3905; // Device Type Mapping const DEVICE_TYPES = { 1: 'Outlet', 3: 'Fan', 7: 'Light' }; // Check Credentials if (!process.env.AC_EMAIL || !process.env.AC_PASSWORD) { console.error('Error: AC_EMAIL and AC_PASSWORD must be set in .env file'); process.exit(1); } // --- DATABASE SETUP --- // Note: Opened in Read/Write mode (default) const db = new Database(DB_FILE); db.exec(` CREATE TABLE IF NOT EXISTS readings ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, dev_id TEXT, dev_name TEXT, port INTEGER, port_name TEXT, temp_c REAL, humidity REAL, vpd REAL, fan_speed INTEGER, on_speed INTEGER, off_speed INTEGER ) `); const insertStmt = db.prepare(` INSERT INTO readings (dev_id, dev_name, port, port_name, temp_c, humidity, vpd, fan_speed, on_speed, off_speed) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); // --- AC INFINITY API LOGIC --- let token = null; async function login() { console.log('Logging in...'); const params = new URLSearchParams(); params.append('appEmail', process.env.AC_EMAIL); params.append('appPasswordl', process.env.AC_PASSWORD); try { const response = await fetch(`${BASE_URL}/api/user/appUserLogin`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': USER_AGENT }, body: params }); const data = await response.json(); if (data.code === 200) { console.log('Login successful.'); return data.data.appId; } else { throw new Error(`Login failed: ${data.msg} (${data.code})`); } } catch (error) { console.error('Login error:', error.message); throw error; } } async function getDeviceList(authToken) { const params = new URLSearchParams(); params.append('userId', authToken); const response = await fetch(`${BASE_URL}/api/user/devInfoListAll`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': USER_AGENT, 'token': authToken, 'phoneType': '1', 'appVersion': '1.9.7' }, body: params }); const data = await response.json(); if (data.code === 200) return data.data || []; throw new Error(`Get device list failed: ${data.msg}`); } async function getDeviceModeSettings(authToken, devId, port) { const params = new URLSearchParams(); params.append('devId', devId); params.append('port', port.toString()); const response = await fetch(`${BASE_URL}/api/dev/getdevModeSettingList`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': USER_AGENT, 'token': authToken, 'phoneType': '1', 'appVersion': '1.9.7', 'minversion': '3.5' }, body: params }); const data = await response.json(); if (data.code === 200) return data.data; console.warn(`Failed to get settings for ${devId}: ${data.msg}`); return null; } async function poll() { try { if (!token) { token = await login(); } const devices = await getDeviceList(token); console.log(`[${new Date().toISOString()}] Data Fetch: Found ${devices.length} controllers.`); for (const device of devices) { const ports = device.deviceInfo && device.deviceInfo.ports ? device.deviceInfo.ports : []; if (ports.length === 0) { console.warn(`Device ${device.devName} has no ports info.`); continue; } for (const portInfo of ports) { // Filter by online status if (portInfo.online === 1) { const port = portInfo.port; const settings = await getDeviceModeSettings(token, device.devId, port); if (settings) { const tempC = settings.temperature ? settings.temperature / 100 : null; const hum = settings.humidity ? settings.humidity / 100 : null; const vpd = settings.vpdnums ? settings.vpdnums / 100 : null; // Determine Port Name let portName = portInfo.portName; if (!portName || portName.startsWith('Port ')) { const typeName = DEVICE_TYPES[settings.atType]; if (typeName) { portName = typeName; } } insertStmt.run( device.devId, device.devName, port, portName, tempC, hum, vpd, settings.speak, settings.onSpead, settings.offSpead ); let label = 'Level'; if (portName === 'Fan') label = 'Fan Speed'; if (portName === 'Light') label = 'Brightness'; console.log(`Saved reading for ${device.devName} (${portName}): ${tempC}°C, ${hum}%, ${label}: ${settings.speak}/10`); } } } } } catch (error) { console.error('Polling error:', error.message); token = null; // Reset token to force re-login } } // --- EXPRESS SERVER --- const app = express(); // API: Devices app.get('/api/devices', (req, res) => { try { const stmt = db.prepare(` SELECT DISTINCT dev_name, port, port_name FROM readings ORDER BY dev_name, port `); const rows = stmt.all(); res.json(rows); } catch (error) { res.status(500).json({ error: error.message }); } }); // API: History app.get('/api/history', (req, res) => { try { const { devName, port, range } = req.query; if (!devName || !port) return res.status(400).json({ error: 'Missing devName or port' }); let timeFilter; switch (range) { case 'week': timeFilter = "-7 days"; break; case 'month': timeFilter = "-30 days"; break; case 'day': default: timeFilter = "-24 hours"; break; } const stmt = db.prepare(` SELECT timestamp || 'Z' as timestamp, temp_c, humidity, vpd, fan_speed, on_speed FROM readings WHERE dev_name = ? AND port = ? AND timestamp >= datetime('now', ?) ORDER BY timestamp ASC `); const rows = stmt.all(devName, parseInt(port, 10), timeFilter); res.json(rows); } catch (error) { console.error(error); res.status(500).json({ error: error.message }); } }); // Webpack Middleware // NOTE: We override publicPath to '/' here because Nginx strips the '/ac/' prefix. // The incoming request for '/ac/bundle.js' becomes '/bundle.js' at this server. const devMiddleware = webpackDevMiddleware(compiler, { publicPath: '/', writeToDisk: false, headers: (req, res, context) => { // Set cache headers for hashed bundle files (immutable) if (req.url && req.url.match(/\.[a-f0-9]{8,}\.(js|css)$/i)) { res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); } } }); app.use(devMiddleware); // Serve index.html for root request (SPA Fallback-ish) app.get('/', (req, res) => { // Access index.html from the memory filesystem // We attempt to read it from the middleware's outputFileSystem const indexFile = path.join(config.output.path, 'index.html'); const fs = devMiddleware.context.outputFileSystem; // Simple wait/retry logic could be added here, but usually startup takes a second. if (fs && fs.existsSync(indexFile)) { const html = fs.readFileSync(indexFile); res.set('Content-Type', 'text/html'); res.send(html); } else { res.status(202).send('Building... Please refresh in a moment.'); } }); // Start Server & Daemon app.listen(PORT, '127.0.0.1', () => { console.log(`Dashboard Server running at http://127.0.0.1:${PORT}`); // Start Polling Loop console.log(`Starting AC Infinity Poll Loop (Interval: ${POLL_INTERVAL_MS}ms)`); // poll(); // Initial run (optional) setInterval(poll, POLL_INTERVAL_MS); });