u
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user