595 lines
22 KiB
JavaScript
595 lines
22 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Wemo SOAP client + SSDP discovery + rules CRUD.
|
|
*
|
|
* Self-contained: no Electron, no store dependency.
|
|
* Adapted from apps/desktop/src/main/wemo.js — same protocol, same SQL schema.
|
|
*/
|
|
|
|
const dgram = require('dgram');
|
|
const path = require('path');
|
|
const http = require('http');
|
|
const axios = require('axios');
|
|
const AdmZip = require('adm-zip');
|
|
const { parseStringPromise } = require('xml2js');
|
|
const { create } = require('xmlbuilder2');
|
|
|
|
// Core helpers — bundled locally so the plugin is self-contained
|
|
const { namesToDayNumbers, timeToSecs } = require('./types');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const NO_KEEPALIVE = new http.Agent({ keepAlive: false });
|
|
const WEMO_PORTS = [49153, 49152, 49154, 49155, 49156];
|
|
const BE_SVC = 'urn:Belkin:service:basicevent:1';
|
|
const BE_URL = '/upnp/control/basicevent1';
|
|
const TS_SVC = 'urn:Belkin:service:timesync:1';
|
|
const TS_URL = '/upnp/control/timesync1';
|
|
const RULES_SVC = 'urn:Belkin:service:rules:1';
|
|
const RULES_URL = '/upnp/control/rules1';
|
|
|
|
const RULE_TYPE_TO_DEVICE = {
|
|
'Schedule': 'Time Interval',
|
|
'Countdown': 'Countdown Rule',
|
|
'Away': 'Away Mode',
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// sql.js (WASM SQLite)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let SQL = null;
|
|
|
|
async function getSql(log) {
|
|
if (!SQL) {
|
|
const fs = require('fs');
|
|
const initSqlJs = require('sql.js');
|
|
|
|
const candidates = [
|
|
path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist', 'sql-wasm.wasm'),
|
|
path.join(__dirname, '..', '..', '..', 'node_modules', 'sql.js', 'dist', 'sql-wasm.wasm'),
|
|
path.join(__dirname, 'sql-wasm.wasm'),
|
|
];
|
|
|
|
let wasmBinary = null;
|
|
for (const p of candidates) {
|
|
try { wasmBinary = fs.readFileSync(p); break; } catch { /* try next */ }
|
|
}
|
|
if (!wasmBinary) {
|
|
throw new Error(`sql-wasm.wasm not found. Tried:\n${candidates.join('\n')}`);
|
|
}
|
|
SQL = await initSqlJs({ wasmBinary });
|
|
}
|
|
return SQL;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SOAP helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function soapRequest(host, port, controlURL, serviceType, action, args = {}, timeoutMs = 10_000) {
|
|
const url = `http://${host}:${port}${controlURL}`;
|
|
const root = create({ version: '1.0', encoding: 'utf-8' })
|
|
.ele('s:Envelope', { 'xmlns:s': 'http://schemas.xmlsoap.org/soap/envelope/', 's:encodingStyle': 'http://schemas.xmlsoap.org/soap/encoding/' })
|
|
.ele('s:Body')
|
|
.ele(`u:${action}`, { [`xmlns:u`]: serviceType });
|
|
for (const [k, v] of Object.entries(args)) root.ele(k).txt(v);
|
|
const xml = root.doc().end({ headless: false });
|
|
|
|
const res = await axios.post(url, xml, {
|
|
headers: {
|
|
'Content-Type': 'text/xml; charset="utf-8"',
|
|
'SOAPACTION': `"${serviceType}#${action}"`,
|
|
'Connection': 'close',
|
|
},
|
|
httpAgent: NO_KEEPALIVE,
|
|
timeout: timeoutMs,
|
|
});
|
|
const parsed = await parseStringPromise(res.data, { explicitArray: false, ignoreAttrs: true });
|
|
const body = parsed['s:Envelope']['s:Body'];
|
|
return body[`u:${action}Response`] ?? body;
|
|
}
|
|
|
|
async function soapWithFallback(host, port, controlURL, serviceType, action, args = {}) {
|
|
const portsToTry = [port, ...WEMO_PORTS.filter((p) => p !== port)];
|
|
let lastErr = null;
|
|
for (const tryPort of portsToTry) {
|
|
try {
|
|
return await soapRequest(host, tryPort, controlURL, serviceType, action, args);
|
|
} catch (err) {
|
|
lastErr = err;
|
|
const isConn = err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT';
|
|
if (!isConn) throw err;
|
|
}
|
|
}
|
|
throw lastErr || new Error(`${host}: all ports failed for ${action}`);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Device control
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function getBinaryState(host, port) {
|
|
const res = await soapWithFallback(host, port, BE_URL, BE_SVC, 'GetBinaryState');
|
|
const raw = String(res['BinaryState'] ?? '0');
|
|
return raw === '1' || raw === '8';
|
|
}
|
|
|
|
async function setBinaryState(host, port, on) {
|
|
await soapWithFallback(host, port, BE_URL, BE_SVC, 'SetBinaryState', { BinaryState: on ? '1' : '0' });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Dimmer control
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function getBrightness(host, port) {
|
|
try {
|
|
const res = await soapWithFallback(host, port, BE_URL, BE_SVC, 'GetBinaryState');
|
|
const raw = String(res['BinaryState'] ?? '0');
|
|
|
|
console.log(`[DWM] Full BinaryState response object:`, res);
|
|
console.log(`[DWM] Raw BinaryState string: "${raw}"`);
|
|
|
|
// For dimmers, check if brightness is available as a separate parameter
|
|
// This matches the Python pywemo implementation
|
|
if (res.brightness !== undefined) {
|
|
const brightness = parseInt(res.brightness, 10);
|
|
console.log(`[DWM] Brightness from separate parameter: ${brightness}`);
|
|
return !isNaN(brightness) ? brightness : null;
|
|
}
|
|
|
|
// Fallback: Check if BinaryState contains brightness info in format: "1|brightness|..."
|
|
// Example: "1|50|0" where 50 is the brightness level (0-100)
|
|
if (raw.includes('|')) {
|
|
const parts = raw.split('|');
|
|
console.log(`[DWM] BinaryState parts:`, parts);
|
|
if (parts.length >= 2) {
|
|
const brightness = parseInt(parts[1], 10);
|
|
console.log(`[DWM] Brightness from pipe format: ${brightness}`);
|
|
return !isNaN(brightness) ? brightness : null;
|
|
}
|
|
}
|
|
|
|
// If device is on but no brightness info, assume 100%
|
|
// If device is off, return 0
|
|
const isOn = raw === '1' || raw === '8';
|
|
console.log(`[DWM] No brightness info, using binary state: ${isOn ? 100 : 0} (raw: "${raw}")`);
|
|
return isOn ? 100 : 0;
|
|
} catch (err) {
|
|
console.log(`[DWM] getBrightness failed: ${err.message}, falling back to binary state`);
|
|
// Fallback for non-dimmer devices
|
|
const isOn = await getBinaryState(host, port);
|
|
return isOn ? 100 : 0;
|
|
}
|
|
}
|
|
|
|
async function setBrightness(host, port, brightness) {
|
|
// Brightness should be 0-100
|
|
const level = Math.max(0, Math.min(100, Math.round(brightness)));
|
|
|
|
console.log(`[DWM] Setting brightness for ${host}:${port} to ${level}%`);
|
|
|
|
if (level === 0) {
|
|
// Turn off the device
|
|
console.log(`[DWM] Brightness 0, turning device off`);
|
|
await setBinaryState(host, port, false);
|
|
} else {
|
|
// For dimmers, use the correct format: BinaryState=1, brightness=level
|
|
// This matches the Python pywemo implementation
|
|
try {
|
|
console.log(`[DWM] Trying dimmer format: BinaryState=1, brightness=${level}`);
|
|
await soapWithFallback(host, port, BE_URL, BE_SVC, 'SetBinaryState', {
|
|
BinaryState: '1',
|
|
brightness: level.toString()
|
|
});
|
|
console.log(`[DWM] Dimmer brightness set successfully`);
|
|
} catch (err) {
|
|
console.log(`[DWM] Dimmer format failed: ${err.message}, falling back to on/off`);
|
|
// Fallback for non-dimmer devices - just turn on
|
|
await setBinaryState(host, port, true);
|
|
}
|
|
}
|
|
}
|
|
|
|
function isDimmerDevice(deviceInfo) {
|
|
if (!deviceInfo) return false;
|
|
|
|
const { productModel, modelDescription, udn, deviceType } = deviceInfo;
|
|
|
|
console.log(`[DWM] Checking if device is dimmer:`, {
|
|
productModel,
|
|
modelDescription,
|
|
udn,
|
|
deviceType
|
|
});
|
|
|
|
// Check various indicators that this is a dimmer
|
|
const isDimmer = (
|
|
(productModel && productModel.toLowerCase().includes('dimmer')) ||
|
|
(modelDescription && modelDescription.toLowerCase().includes('dimmer')) ||
|
|
(udn && udn.toLowerCase().includes('dimmer')) ||
|
|
(deviceType && deviceType.toLowerCase().includes('dimmer')) ||
|
|
(productModel && productModel.includes('WDS060')) ||
|
|
(modelDescription && modelDescription.includes('Dimmer')) ||
|
|
(udn && udn.includes('Dimmer-1_0')) ||
|
|
(productModel && productModel.includes('WDS')) ||
|
|
(modelDescription && modelDescription.toLowerCase().includes('light') && modelDescription.toLowerCase().includes('dim'))
|
|
);
|
|
|
|
console.log(`[DWM] Dimmer detection result: ${isDimmer}`);
|
|
return isDimmer;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Device info
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function resolveProductModel(udn, deviceType, firmwareSuffix) {
|
|
const udnBase = String(udn || '').replace(/^uuid:/i, '');
|
|
const parts = udnBase.split('-');
|
|
const udnPrefix = parts.slice(0, 2).join('-').toLowerCase();
|
|
const udnType = parts[0].toLowerCase();
|
|
const fwSuffix = String(firmwareSuffix || '').toUpperCase();
|
|
const dt = String(deviceType || '').toLowerCase();
|
|
|
|
if (udnPrefix === 'lightswitch-3_0') return 'Wemo 3-Way Smart Switch (WLS0403)';
|
|
if (udnPrefix === 'lightswitch-2_0') return 'Wemo Light Switch (WLS040)';
|
|
if (udnPrefix === 'lightswitch-1_0') {
|
|
if (fwSuffix.includes('OWRT-LS')) return 'Wemo Light Switch (F7C030)';
|
|
return 'Wemo Light Switch (WLS040)';
|
|
}
|
|
if (udnType === 'dimmer' || dt.includes('dimmer') || fwSuffix.includes('WDS'))
|
|
return 'Wemo WiFi Smart Dimmer (WDS060)';
|
|
if (udnType === 'insight' || dt.includes('insight')) return 'Wemo Insight Smart Plug (F7C029)';
|
|
if (udnPrefix === 'socket-2_0') return 'Wemo Mini Smart Plug (F7C063)';
|
|
if (udnPrefix === 'socket-1_0') {
|
|
if (fwSuffix.includes('OWRT-SNS')) return 'Wemo Switch (F7C027)';
|
|
return 'Wemo Smart Plug';
|
|
}
|
|
if (udnType === 'socket') return 'Wemo Smart Plug';
|
|
return null;
|
|
}
|
|
|
|
async function getDeviceInfo(host, port) {
|
|
const results = {};
|
|
try {
|
|
const res = await soapWithFallback(host, port, BE_URL, BE_SVC, 'GetFriendlyName');
|
|
results.friendlyName = String(res['FriendlyName'] ?? '').trim();
|
|
} catch { results.friendlyName = null; }
|
|
|
|
try {
|
|
const sx = await axios.get(`http://${host}:${port}/setup.xml`, { timeout: 5000, httpAgent: NO_KEEPALIVE });
|
|
const fwMatch = sx.data.match(/<firmwareVersion>([^<]+)<\/firmwareVersion>/i);
|
|
const udnMatch = sx.data.match(/<UDN>([^<]+)<\/UDN>/i);
|
|
const dtMatch = sx.data.match(/<deviceType>([^<]+)<\/deviceType>/i);
|
|
const mdMatch = sx.data.match(/<modelDescription>([^<]+)<\/modelDescription>/i);
|
|
results.firmwareVersion = fwMatch ? fwMatch[1].trim() : null;
|
|
results.modelDescription = mdMatch ? mdMatch[1].trim() : null;
|
|
if (udnMatch) {
|
|
results.udn = udnMatch[1].trim();
|
|
const fw = results.firmwareVersion || '';
|
|
const fwSuffix = fw.split('PVT-').pop() || '';
|
|
results.productModel = resolveProductModel(results.udn, dtMatch ? dtMatch[1] : '', fwSuffix);
|
|
}
|
|
} catch { /* non-fatal */ }
|
|
return results;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SSDP Discovery
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function discoverDevices(timeoutMs = 10_000) {
|
|
return new Promise((resolve) => {
|
|
const SSDP_ADDR = '239.255.255.250';
|
|
const SSDP_PORT = 1900;
|
|
const M_SEARCH = [
|
|
'M-SEARCH * HTTP/1.1',
|
|
`HOST: ${SSDP_ADDR}:${SSDP_PORT}`,
|
|
'MAN: "ssdp:discover"',
|
|
'MX: 3',
|
|
'ST: urn:Belkin:device:**',
|
|
'', '',
|
|
].join('\r\n');
|
|
|
|
const found = new Map();
|
|
const sock = dgram.createSocket({ type: 'udp4', reuseAddr: true });
|
|
|
|
sock.on('message', async (msg) => {
|
|
const text = msg.toString();
|
|
const locMatch = text.match(/LOCATION:\s*(http:\/\/([^:]+):(\d+)\/setup\.xml)/i);
|
|
if (!locMatch) return;
|
|
const [, , ip, portStr] = locMatch;
|
|
const port = parseInt(portStr, 10);
|
|
const key = `${ip}:${port}`;
|
|
if (found.has(key)) return;
|
|
found.set(key, { host: ip, port, discovering: true });
|
|
try {
|
|
const info = await getDeviceInfo(ip, port);
|
|
found.set(key, { host: ip, port, ...info });
|
|
} catch { /* keep partial entry */ }
|
|
});
|
|
|
|
sock.bind(() => {
|
|
const buf = Buffer.from(M_SEARCH);
|
|
sock.send(buf, 0, buf.length, SSDP_PORT, SSDP_ADDR);
|
|
});
|
|
|
|
setTimeout(() => {
|
|
try { sock.close(); } catch { /* ignore */ }
|
|
resolve(Array.from(found.values()));
|
|
}, timeoutMs);
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Rules — fetch (ZIP + SQLite)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function fetchRules(host, port) {
|
|
const res = await soapWithFallback(host, port, RULES_URL, RULES_SVC, 'FetchRules');
|
|
const version = String(res['ruleDbVersion'] ?? '0');
|
|
const dbUrl = String(res['ruleDbPath'] ?? '');
|
|
if (!dbUrl) throw new Error('FetchRules returned no ruleDbPath');
|
|
|
|
const dlRes = await axios.get(dbUrl, { responseType: 'arraybuffer', timeout: 15_000 });
|
|
const zip = new AdmZip(Buffer.from(dlRes.data));
|
|
const entry = zip.getEntries().find((e) => e.entryName.endsWith('.db'));
|
|
if (!entry) throw new Error('No .db file in rules ZIP');
|
|
|
|
const SQL = await getSql();
|
|
const db = new SQL.Database(entry.getData());
|
|
|
|
const rules = _dbQuery(db, 'SELECT * FROM RULES');
|
|
const ruleDevices = _dbQuery(db, 'SELECT * FROM RULEDEVICES');
|
|
const targets = _dbQuery(db, 'SELECT * FROM TARGETDEVICES');
|
|
db.close();
|
|
|
|
return { version, rules, ruleDevices, targets };
|
|
}
|
|
|
|
function _dbQuery(db, sql) {
|
|
const rows = [];
|
|
const stmt = db.prepare(sql);
|
|
while (stmt.step()) rows.push(stmt.getAsObject());
|
|
stmt.free();
|
|
return rows;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Rules — store (ZIP + CDATA encode)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function storeRules(host, port, version, dbBuffer) {
|
|
const zip = new AdmZip();
|
|
zip.addFile('temppluginRules.db', dbBuffer);
|
|
const b64 = zip.toBuffer().toString('base64');
|
|
|
|
// CRITICAL: body must be entity-encoded CDATA — hand-crafted XML only
|
|
const soapXml = `<?xml version="1.0" encoding="utf-8"?>
|
|
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
|
<s:Body>
|
|
<u:StoreRules xmlns:u="urn:Belkin:service:rules:1">
|
|
<ruleDbVersion>${version}</ruleDbVersion>
|
|
<StartSync>NOSYNC</StartSync>
|
|
<ruleDbBody><![CDATA[${b64}]]></ruleDbBody>
|
|
</u:StoreRules>
|
|
</s:Body>
|
|
</s:Envelope>`;
|
|
|
|
const url = `http://${host}:${port}${RULES_URL}`;
|
|
const res = await axios.post(url, soapXml, {
|
|
headers: {
|
|
'Content-Type': 'text/xml; charset="utf-8"',
|
|
'SOAPACTION': `"${RULES_SVC}#StoreRules"`,
|
|
'Connection': 'close',
|
|
},
|
|
httpAgent: NO_KEEPALIVE,
|
|
timeout: 20_000,
|
|
});
|
|
if (String(res.data).includes('failed')) throw new Error('StoreRules: device returned failure');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Rules — create / update / delete / toggle
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function createRule(host, port, ruleData) {
|
|
const SQL = await getSql();
|
|
const { version, rules, ruleDevices, targets } = await fetchRules(host, port);
|
|
const db = new SQL.Database();
|
|
_createSchema(db);
|
|
for (const r of rules) _insertRule(db, r);
|
|
for (const r of ruleDevices) _insertRuleDevice(db, r);
|
|
for (const r of targets) _insertTargetDevice(db, r);
|
|
|
|
const newId = _nextRuleId(db);
|
|
_insertNewRule(db, newId, ruleData);
|
|
|
|
const buf = Buffer.from(db.export());
|
|
db.close();
|
|
await storeRules(host, port, String(parseInt(version, 10) + 2), buf);
|
|
return newId;
|
|
}
|
|
|
|
async function updateRule(host, port, ruleId, ruleData) {
|
|
const SQL = await getSql();
|
|
const { version, rules, ruleDevices, targets } = await fetchRules(host, port);
|
|
const db = new SQL.Database();
|
|
_createSchema(db);
|
|
for (const r of rules) _insertRule(db, r);
|
|
for (const r of ruleDevices) _insertRuleDevice(db, r);
|
|
for (const r of targets) _insertTargetDevice(db, r);
|
|
|
|
db.run('DELETE FROM RULES WHERE RuleID = ?', [String(ruleId)]);
|
|
db.run('DELETE FROM RULEDEVICES WHERE RuleID = ?', [String(ruleId)]);
|
|
db.run('DELETE FROM TARGETDEVICES WHERE RuleID = ?', [String(ruleId)]);
|
|
_insertNewRule(db, ruleId, ruleData);
|
|
|
|
const buf = Buffer.from(db.export());
|
|
db.close();
|
|
await storeRules(host, port, String(parseInt(version, 10) + 2), buf);
|
|
}
|
|
|
|
async function deleteRule(host, port, ruleId) {
|
|
const SQL = await getSql();
|
|
const { version, rules, ruleDevices, targets } = await fetchRules(host, port);
|
|
const db = new SQL.Database();
|
|
_createSchema(db);
|
|
for (const r of rules) _insertRule(db, r);
|
|
for (const r of ruleDevices) _insertRuleDevice(db, r);
|
|
for (const r of targets) _insertTargetDevice(db, r);
|
|
|
|
db.run('DELETE FROM RULES WHERE RuleID = ?', [String(ruleId)]);
|
|
db.run('DELETE FROM RULEDEVICES WHERE RuleID = ?', [String(ruleId)]);
|
|
db.run('DELETE FROM TARGETDEVICES WHERE RuleID = ?', [String(ruleId)]);
|
|
|
|
const buf = Buffer.from(db.export());
|
|
db.close();
|
|
await storeRules(host, port, String(parseInt(version, 10) + 2), buf);
|
|
}
|
|
|
|
async function toggleRule(host, port, ruleId, enabled) {
|
|
const SQL = await getSql();
|
|
const { version, rules, ruleDevices, targets } = await fetchRules(host, port);
|
|
const db = new SQL.Database();
|
|
_createSchema(db);
|
|
for (const r of rules) _insertRule(db, r);
|
|
for (const r of ruleDevices) _insertRuleDevice(db, r);
|
|
for (const r of targets) _insertTargetDevice(db, r);
|
|
|
|
db.run('UPDATE RULES SET State = ? WHERE RuleID = ?', [enabled ? '1' : '0', String(ruleId)]);
|
|
|
|
const buf = Buffer.from(db.export());
|
|
db.close();
|
|
await storeRules(host, port, String(parseInt(version, 10) + 2), buf);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SQLite helpers (schema + insert helpers)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function _createSchema(db) {
|
|
db.run(`CREATE TABLE IF NOT EXISTS RULES (
|
|
RuleID TEXT, Name TEXT, Type TEXT, RuleOrder INTEGER,
|
|
StartDate TEXT DEFAULT '12201982', EndDate TEXT DEFAULT '07301982',
|
|
State TEXT DEFAULT '1', Sync TEXT DEFAULT 'NOSYNC'
|
|
)`);
|
|
db.run(`CREATE TABLE IF NOT EXISTS RULEDEVICES (
|
|
RuleDevicePK INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
RuleID TEXT, DeviceID TEXT, GroupID INTEGER, DayID INTEGER,
|
|
StartTime INTEGER, RuleDuration INTEGER, StartAction INTEGER, EndAction INTEGER,
|
|
SensorDuration INTEGER, Type INTEGER, Value INTEGER, Level INTEGER,
|
|
ZBCapabilityStart TEXT, ZBCapabilityEnd TEXT,
|
|
OnModeOffset INTEGER, OffModeOffset INTEGER, CountdownTime INTEGER, EndTime INTEGER
|
|
)`);
|
|
db.run(`CREATE TABLE IF NOT EXISTS TARGETDEVICES (
|
|
TargetDevicesPK INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
RuleID TEXT, DeviceID TEXT, DeviceIndex INTEGER
|
|
)`);
|
|
}
|
|
|
|
function _insertRule(db, r) {
|
|
db.run(
|
|
'INSERT INTO RULES (RuleID,Name,Type,RuleOrder,StartDate,EndDate,State,Sync) VALUES (?,?,?,?,?,?,?,?)',
|
|
[r.RuleID, r.Name, r.Type, r.RuleOrder, r.StartDate ?? '12201982', r.EndDate ?? '07301982', r.State ?? '1', r.Sync ?? 'NOSYNC']
|
|
);
|
|
}
|
|
|
|
function _insertRuleDevice(db, r) {
|
|
db.run(
|
|
`INSERT INTO RULEDEVICES (RuleID,DeviceID,GroupID,DayID,StartTime,RuleDuration,StartAction,EndAction,
|
|
SensorDuration,Type,Value,Level,ZBCapabilityStart,ZBCapabilityEnd,
|
|
OnModeOffset,OffModeOffset,CountdownTime,EndTime)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
[r.RuleID, r.DeviceID, r.GroupID ?? 0, r.DayID, r.StartTime, r.RuleDuration ?? 0,
|
|
r.StartAction, r.EndAction ?? -1, r.SensorDuration ?? 0, r.Type ?? 0, r.Value ?? 0,
|
|
r.Level ?? 0, r.ZBCapabilityStart ?? '', r.ZBCapabilityEnd ?? '',
|
|
r.OnModeOffset ?? 0, r.OffModeOffset ?? 0, r.CountdownTime ?? 0, r.EndTime ?? -1]
|
|
);
|
|
}
|
|
|
|
function _insertTargetDevice(db, r) {
|
|
db.run(
|
|
'INSERT INTO TARGETDEVICES (RuleID,DeviceID,DeviceIndex) VALUES (?,?,?)',
|
|
[r.RuleID, r.DeviceID, r.DeviceIndex ?? 0]
|
|
);
|
|
}
|
|
|
|
function _nextRuleId(db) {
|
|
const stmt = db.prepare('SELECT CAST(MAX(CAST(RuleID AS INTEGER)) AS INTEGER) AS mx FROM RULES');
|
|
let mx = 0;
|
|
if (stmt.step()) { mx = stmt.getAsObject().mx ?? 0; }
|
|
stmt.free();
|
|
return mx + 1;
|
|
}
|
|
|
|
function _insertNewRule(db, ruleId, ruleData) {
|
|
// namesToDayNumbers + timeToSecs already required at top of file
|
|
const days = ruleData.days ?? [];
|
|
const dayNums = typeof days[0] === 'string' ? namesToDayNumbers(days) : days.map(Number);
|
|
const devId = ruleData.deviceId ?? ruleData.udn ?? '';
|
|
const ruleType = RULE_TYPE_TO_DEVICE[ruleData.type] ?? ruleData.type ?? 'Time Interval';
|
|
|
|
let startSecs, endSecs;
|
|
if (ruleData.startTime != null) {
|
|
startSecs = typeof ruleData.startTime === 'string'
|
|
? timeToSecs(ruleData.startTime) : Number(ruleData.startTime);
|
|
} else startSecs = 0;
|
|
|
|
if (ruleData.endTime != null && ruleData.endTime !== '') {
|
|
endSecs = typeof ruleData.endTime === 'string'
|
|
? timeToSecs(ruleData.endTime) : Number(ruleData.endTime);
|
|
} else endSecs = -1;
|
|
|
|
const startAction = ruleData.startAction ?? 1;
|
|
const endAction = ruleData.endAction ?? -1;
|
|
|
|
db.run(
|
|
'INSERT INTO RULES (RuleID,Name,Type,RuleOrder,StartDate,EndDate,State,Sync) VALUES (?,?,?,?,?,?,?,?)',
|
|
[String(ruleId), ruleData.name ?? 'Rule', ruleType, ruleId,
|
|
'12201982', '07301982', ruleData.enabled !== false ? '1' : '0', 'NOSYNC']
|
|
);
|
|
|
|
for (const dayId of dayNums) {
|
|
db.run(
|
|
`INSERT INTO RULEDEVICES (RuleID,DeviceID,GroupID,DayID,StartTime,RuleDuration,StartAction,EndAction,
|
|
SensorDuration,Type,Value,Level,ZBCapabilityStart,ZBCapabilityEnd,
|
|
OnModeOffset,OffModeOffset,CountdownTime,EndTime)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
[String(ruleId), devId, 0, dayId, startSecs, 0,
|
|
startAction, endAction, 0, 0, 0, 0, '', '',
|
|
0, 0, ruleData.countdownTime ?? 0, endSecs]
|
|
);
|
|
}
|
|
|
|
db.run(
|
|
'INSERT INTO TARGETDEVICES (RuleID,DeviceID,DeviceIndex) VALUES (?,?,?)',
|
|
[String(ruleId), devId, 0]
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Exports
|
|
// ---------------------------------------------------------------------------
|
|
|
|
module.exports = {
|
|
getBinaryState,
|
|
setBinaryState,
|
|
getBrightness,
|
|
setBrightness,
|
|
isDimmerDevice,
|
|
getDeviceInfo,
|
|
discoverDevices,
|
|
fetchRules,
|
|
storeRules,
|
|
createRule,
|
|
updateRule,
|
|
deleteRule,
|
|
toggleRule,
|
|
};
|