diff --git a/agents/ac-infinity/src/ac-client.js b/agents/ac-infinity/src/ac-client.js index 8368fff..26ed3c8 100644 --- a/agents/ac-infinity/src/ac-client.js +++ b/agents/ac-infinity/src/ac-client.js @@ -141,8 +141,6 @@ export class ACInfinityClient { .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, ''); - console.log(`[AC] Debug: Device ${deviceId} (${device.devName}) Type: ${device.devType}`); - // --- Device Level Sensors --- // Temperature (Celsius * 100) @@ -274,10 +272,6 @@ export class ACInfinityClient { const settings = await this.getDeviceModeSettings(devId, port); if (!settings) throw new Error('Could not fetch existing settings'); - console.log(`[AC] Debug: Device Settings for ${devId}:${port}`, JSON.stringify(settings)); - // Log device info if available to check type? We don't have devType here easily without passing it or fetching list again. - // But settings usually contains some info. - // 2. Prepare updates // Constrain level 0-10 const safeLevel = Math.max(0, Math.min(10, Math.round(level))); @@ -345,10 +339,7 @@ export class ACInfinityClient { if (!params.has('transitionType')) params.append('transitionType', '0'); // 3. Send update - const requestUrl = `${this.host}/api/dev/addDevMode?${params.toString()}`; - console.log(`[AC] Debug: Sending request to ${requestUrl}`); - - const response = await fetch(requestUrl, { + const response = await fetch(`${this.host}/api/dev/addDevMode?${params.toString()}`, { method: 'POST', headers: { 'User-Agent': 'ACController/1.9.7 (com.acinfinity.humiture; build:533; iOS 18.5.0) Alamofire/5.10.2', diff --git a/uiserver/webpack.config.js b/uiserver/webpack.config.js index 4572d77..fe53c85 100644 --- a/uiserver/webpack.config.js +++ b/uiserver/webpack.config.js @@ -246,6 +246,10 @@ function handleAgentMessage(ws, message, clientState, clientId) { try { const validReadings = readings.filter(r => r.device && r.channel && (r.value !== undefined || r.data !== undefined)); const result = insertReadingsSmart(clientState.devicePrefix, validReadings); + + // Trigger rules immediately on new data + if (runRules) runRules(); + ws.send(JSON.stringify({ type: 'ack', count: result.inserted + result.updated })); } catch (err) { console.error('[WS] Error inserting readings:', err.message); @@ -321,6 +325,202 @@ function syncOutputStates() { // Start output state sync interval (every 60s) setInterval(syncOutputStates, 60000); + +// ============================================= +// RULE ENGINE (Global Scope) +// ============================================= + +// Virtual output channel definitions +const OUTPUT_CHANNELS = [ + { channel: 'CircFanLevel', type: 'number', min: 0, max: 10, description: 'Circulation Fan Level' }, + { channel: 'CO2Valve', type: 'boolean', min: 0, max: 1, description: 'CO2 Valve' }, + { channel: 'BigDehumid', type: 'boolean', min: 0, max: 1, description: 'Big Dehumidifier' }, + { channel: 'TentExhaust', type: 'boolean', min: 0, max: 1, description: 'Tent Exhaust Fan' }, +]; + +// Get current sensor value +function getSensorValue(channel) { + // channel format: "device:channel" e.g. "ac:controller:co2" + const lastColonIndex = channel.lastIndexOf(':'); + if (lastColonIndex === -1) return null; + const device = channel.substring(0, lastColonIndex); + const ch = channel.substring(lastColonIndex + 1); + + const stmt = db.prepare(` + SELECT value FROM sensor_events + WHERE device = ? AND channel = ? + ORDER BY timestamp DESC LIMIT 1 + `); + const row = stmt.get(device, ch); + return row ? row.value : null; +} + +// Get current output value +function getOutputValue(channel) { + const stmt = db.prepare(` + SELECT value FROM output_events + WHERE channel = ? + ORDER BY timestamp DESC LIMIT 1 + `); + const row = stmt.get(channel); + return row ? row.value : 0; +} + +// Write output value with RLE +function writeOutputValue(channel, value) { + const now = new Date().toISOString(); + + const lastStmt = db.prepare(` + SELECT id, value FROM output_events + WHERE channel = ? + ORDER BY timestamp DESC LIMIT 1 + `); + const last = lastStmt.get(channel); + + const valueChanged = !last || Math.abs(last.value - value) >= Number.EPSILON; + + if (!valueChanged) { + // Same value - update the until timestamp (RLE) + const updateStmt = db.prepare('UPDATE output_events SET until = ? WHERE id = ?'); + updateStmt.run(now, last.id); + } else { + // New value - insert new record + const insertStmt = db.prepare(` + INSERT INTO output_events (timestamp, until, channel, value, data_type) + VALUES (?, NULL, ?, ?, 'number') + `); + insertStmt.run(now, channel, value); + console.log(`[RuleRunner] Output changed: ${channel} = ${value}`); + + // Send command to bound physical device + const binding = OUTPUT_BINDINGS[channel]; + if (binding) { + let commandValue = value; + if (binding.type === 'switch') { + commandValue = value > 0 ? 1 : 0; + } + + console.log(`[RuleRunner] Binding for ${channel}: type=${binding.type}, val=${value}, cmdVal=${commandValue}`); + + sendCommandToDevicePrefix(`${binding.device}:`, { + device: binding.channel, + action: 'set_state', + value: commandValue + }); + } + } +} + +// Compare values with operator +function compareValues(actual, operator, target) { + if (actual === null || actual === undefined) return false; + switch (operator) { + case '=': + case '==': return actual === target; + case '!=': return actual !== target; + case '<': return actual < target; + case '>': return actual > target; + case '<=': return actual <= target; + case '>=': return actual >= target; + default: return false; + } +} + +// Evaluate a single condition +function evaluateCondition(condition) { + const { type, operator, value, channel } = condition; + + // Handle AND/OR groups + if (operator === 'AND' || operator === 'OR') { + const results = (condition.conditions || []).map(c => evaluateCondition(c)); + return operator === 'AND' + ? results.every(r => r) + : results.some(r => r); + } + + switch (type) { + case 'time': { + const now = new Date(); + const currentTime = now.getHours() * 60 + now.getMinutes(); // minutes since midnight + + if (operator === 'between' && Array.isArray(value)) { + const [start, end] = value.map(t => { + const [h, m] = t.split(':').map(Number); + return h * 60 + m; + }); + return currentTime >= start && currentTime <= end; + } + + const [h, m] = String(value).split(':').map(Number); + const targetTime = h * 60 + m; + return compareValues(currentTime, operator, targetTime); + } + + case 'date': { + const now = new Date(); + const today = now.toISOString().split('T')[0]; + + if (operator === 'between' && Array.isArray(value)) { + return today >= value[0] && today <= value[1]; + } + if (operator === 'before') return today < value; + if (operator === 'after') return today > value; + return today === value; + } + + case 'sensor': { + const sensorValue = getSensorValue(channel); + return compareValues(sensorValue, operator, value); + } + + case 'output': { + const outputValue = getOutputValue(channel); + return compareValues(outputValue, operator, value); + } + + default: + console.warn(`[RuleRunner] Unknown condition type: ${type}`); + return false; + } +} + +// Run all rules +function runRules() { + if (!db) return; + + try { + const rules = db.prepare('SELECT * FROM rules WHERE enabled = 1 ORDER BY position ASC').all(); + + // Default all outputs to OFF (0) - if no rule sets them, they stay off + const desiredOutputs = {}; + for (const ch of OUTPUT_CHANNELS) { + desiredOutputs[ch.channel] = 0; + } + + for (const rule of rules) { + try { + const conditions = JSON.parse(rule.conditions || '{}'); + const action = JSON.parse(rule.action || '{}'); + + if (evaluateCondition(conditions)) { + // Rule matches - set output (later rules override) + if (action.channel && action.value !== undefined) { + desiredOutputs[action.channel] = action.value; + } + } + } catch (err) { + console.error(`[RuleRunner] Error evaluating rule ${rule.id}:`, err.message); + } + } + + // Write output values + for (const [channel, value] of Object.entries(desiredOutputs)) { + writeOutputValue(channel, value); + } + } catch (err) { + console.error('[RuleRunner] Error running rules:', err.message); + } +} // Also sync immediately on startup after a short delay setTimeout(syncOutputStates, 5000); @@ -531,12 +731,7 @@ module.exports = { app.use('/api/rules', checkAuth); // Virtual output channel definitions - const OUTPUT_CHANNELS = [ - { channel: 'CircFanLevel', type: 'number', min: 0, max: 10, description: 'Circulation Fan Level' }, - { channel: 'CO2Valve', type: 'boolean', min: 0, max: 1, description: 'CO2 Valve' }, - { channel: 'BigDehumid', type: 'boolean', min: 0, max: 1, description: 'Big Dehumidifier' }, - { channel: 'TentExhaust', type: 'boolean', min: 0, max: 1, description: 'Tent Exhaust Fan' }, - ]; + // Virtual output channel definitions - MOVED TO GLOBAL SCOPE // GET /api/outputs - List output channel definitions app.get('/api/outputs', (req, res) => { @@ -642,6 +837,7 @@ module.exports = { JSON.stringify(action), req.user?.id || null ); + runRules(); // Trigger rules immediately res.json({ id: info.lastInsertRowid, name, type, enabled, conditions, action }); } catch (err) { res.status(500).json({ error: err.message }); @@ -665,6 +861,7 @@ module.exports = { req.params.id ); if (info.changes > 0) { + runRules(); // Trigger rules immediately res.json({ id: req.params.id, name, type, enabled, conditions, action }); } else { res.status(404).json({ error: 'Rule not found' }); @@ -680,6 +877,7 @@ module.exports = { const stmt = db.prepare('DELETE FROM rules WHERE id = ?'); const info = stmt.run(req.params.id); if (info.changes > 0) { + runRules(); // Trigger rules immediately res.json({ success: true }); } else { res.status(404).json({ error: 'Rule not found' }); @@ -703,6 +901,7 @@ module.exports = { try { updateMany(order); + runRules(); // Trigger rules immediately res.json({ success: true }); } catch (err) { res.status(500).json({ error: err.message }); @@ -713,191 +912,7 @@ module.exports = { // RULE RUNNER (Background Job) // ============================================= - // Get current sensor value - function getSensorValue(channel) { - // channel format: "device:channel" e.g. "ac:controller:co2" - const lastColonIndex = channel.lastIndexOf(':'); - if (lastColonIndex === -1) return null; - const device = channel.substring(0, lastColonIndex); - const ch = channel.substring(lastColonIndex + 1); - - const stmt = db.prepare(` - SELECT value FROM sensor_events - WHERE device = ? AND channel = ? - ORDER BY timestamp DESC LIMIT 1 - `); - const row = stmt.get(device, ch); - return row ? row.value : null; - } - - // Get current output value - function getOutputValue(channel) { - const stmt = db.prepare(` - SELECT value FROM output_events - WHERE channel = ? - ORDER BY timestamp DESC LIMIT 1 - `); - const row = stmt.get(channel); - return row ? row.value : 0; - } - - // Write output value with RLE - function writeOutputValue(channel, value) { - const now = new Date().toISOString(); - - const lastStmt = db.prepare(` - SELECT id, value FROM output_events - WHERE channel = ? - ORDER BY timestamp DESC LIMIT 1 - `); - const last = lastStmt.get(channel); - - // Debug log removed - - const valueChanged = !last || Math.abs(last.value - value) >= Number.EPSILON; - - if (!valueChanged) { - // Same value - update the until timestamp (RLE) - const updateStmt = db.prepare('UPDATE output_events SET until = ? WHERE id = ?'); - updateStmt.run(now, last.id); - } else { - // New value - insert new record - const insertStmt = db.prepare(` - INSERT INTO output_events (timestamp, until, channel, value, data_type) - VALUES (?, NULL, ?, ?, 'number') - `); - insertStmt.run(now, channel, value); - console.log(`[RuleRunner] Output changed: ${channel} = ${value}`); - - // Send command to bound physical device - const binding = OUTPUT_BINDINGS[channel]; - if (binding) { - let commandValue = value; - if (binding.type === 'switch') { - commandValue = value > 0 ? 1 : 0; - } - - console.log(`[RuleRunner] Binding for ${channel}: type=${binding.type}, val=${value}, cmdVal=${commandValue}`); - - sendCommandToDevicePrefix(`${binding.device}:`, { - device: binding.channel, - action: 'set_state', - value: commandValue - }); - } - } - } - - // Compare values with operator - function compareValues(actual, operator, target) { - if (actual === null || actual === undefined) return false; - switch (operator) { - case '=': - case '==': return actual === target; - case '!=': return actual !== target; - case '<': return actual < target; - case '>': return actual > target; - case '<=': return actual <= target; - case '>=': return actual >= target; - default: return false; - } - } - - // Evaluate a single condition - function evaluateCondition(condition) { - const { type, operator, value, channel } = condition; - - // Handle AND/OR groups - if (operator === 'AND' || operator === 'OR') { - const results = (condition.conditions || []).map(c => evaluateCondition(c)); - return operator === 'AND' - ? results.every(r => r) - : results.some(r => r); - } - - switch (type) { - case 'time': { - const now = new Date(); - const currentTime = now.getHours() * 60 + now.getMinutes(); // minutes since midnight - - if (operator === 'between' && Array.isArray(value)) { - const [start, end] = value.map(t => { - const [h, m] = t.split(':').map(Number); - return h * 60 + m; - }); - return currentTime >= start && currentTime <= end; - } - - const [h, m] = String(value).split(':').map(Number); - const targetTime = h * 60 + m; - return compareValues(currentTime, operator, targetTime); - } - - case 'date': { - const now = new Date(); - const today = now.toISOString().split('T')[0]; - - if (operator === 'between' && Array.isArray(value)) { - return today >= value[0] && today <= value[1]; - } - if (operator === 'before') return today < value; - if (operator === 'after') return today > value; - return today === value; - } - - case 'sensor': { - const sensorValue = getSensorValue(channel); - return compareValues(sensorValue, operator, value); - } - - case 'output': { - const outputValue = getOutputValue(channel); - return compareValues(outputValue, operator, value); - } - - default: - console.warn(`[RuleRunner] Unknown condition type: ${type}`); - return false; - } - } - - // Run all rules - function runRules() { - if (!db) return; - - try { - const rules = db.prepare('SELECT * FROM rules WHERE enabled = 1 ORDER BY position ASC').all(); - - // Default all outputs to OFF (0) - if no rule sets them, they stay off - const desiredOutputs = {}; - for (const ch of OUTPUT_CHANNELS) { - desiredOutputs[ch.channel] = 0; - } - - for (const rule of rules) { - try { - const conditions = JSON.parse(rule.conditions || '{}'); - const action = JSON.parse(rule.action || '{}'); - - if (evaluateCondition(conditions)) { - // Rule matches - set output (later rules override) - if (action.channel && action.value !== undefined) { - desiredOutputs[action.channel] = action.value; - } - } - } catch (err) { - console.error(`[RuleRunner] Error evaluating rule ${rule.id}:`, err.message); - } - } - - // Write output values - for (const [channel, value] of Object.entries(desiredOutputs)) { - writeOutputValue(channel, value); - } - } catch (err) { - console.error('[RuleRunner] Error running rules:', err.message); - } - } + // Rule Engine functions moved to global scope // Start rule runner (every 10 seconds) const ruleRunnerInterval = setInterval(runRules, 10000);