Genesis
This commit is contained in:
355
server.js
Normal file
355
server.js
Normal file
@@ -0,0 +1,355 @@
|
||||
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 bcrypt from 'bcrypt';
|
||||
import jwt from 'jsonwebtoken';
|
||||
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;
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production';
|
||||
|
||||
// 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
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const getUserByUsername = db.prepare('SELECT * FROM users WHERE username = ?');
|
||||
|
||||
// --- 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();
|
||||
app.use(express.json());
|
||||
|
||||
// Auth: Login
|
||||
app.post('/api/auth/login', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username and password required' });
|
||||
}
|
||||
|
||||
const user = getUserByUsername.get(username);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, user.password_hash);
|
||||
if (!valid) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const token = jwt.sign(
|
||||
{ id: user.id, username: user.username, role: user.role },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
|
||||
res.json({ token, user: { username: user.username, role: user.role } });
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Auth: Get current user
|
||||
app.get('/api/auth/me', (req, res) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
res.json({ user: { username: decoded.username, role: decoded.role } });
|
||||
} catch (error) {
|
||||
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
});
|
||||
Reference in New Issue
Block a user