//# sourceURL=J_ReactorSensor_UI7.js /** * J_ReactorSensor_UI7.js * Configuration interface for ReactorSensor * * Copyright 2018-2022 Patrick H. Rigney, All Rights Reserved. * This file is part of Reactor. Use subject to license; please see * license details at https://www.toggledbits.com/static/reactor/docs/Installation#license-and-use-restrictions * */ /* globals api,jQuery,ace,MultiBox,ALTUI_revision,Promise,escape,unescape */ /* jshint browser: true, devel: true, multistr: true, laxcomma: true, undef: true, unused: false */ //"use strict"; // fails on UI7, works fine with ALTUI var ReactorSensor = (function(api, $) { /* unique identifier for this plugin... */ var uuid = '21b5725a-6dcd-11e8-8342-74d4351650de'; var pluginVersion = "3.11 (22314)"; var _UIVERSION = 22314; /* must coincide with Lua core */ var _CDATAVERSION = 20045; /* must coincide with Lua core */ var DEVINFO_MINSERIAL = 482; var _DOCURL = "https://www.toggledbits.com/static/reactor/docs/3.9/"; var _FORUMURL = "https://community.getvera.com/c/plugins-and-plugin-development/reactor/178"; var _MIN_ALTUI_VERSION = 2536; var _MAX_ALTUI_VERSION = 2553; var myModule = {}; var serviceId = "urn:toggledbits-com:serviceId:ReactorSensor"; var deviceType = "urn:schemas-toggledbits-com:device:ReactorSensor:1"; var moduleReady = false; var needsRestart = false; var iData = []; var roomsByName = false; var actions = {}; var deviceActionData = {}; var deviceInfo = {}; var userIx = {}; var userNameIx = {}; var configModified = false; var inStatusPanel = false; var spyDevice = false; var lastx = 0; var isOpenLuup = false; var isALTUI = false; var devVeraAlerts = false; var devVeraTelegram = false; var dateFormat = "%F"; /* ISO8601 defaults */ var timeFormat = "%T"; var unsafeLua = true; var onBeforeCpanelClose; /* forward declaration */ var condTypeName = { "comment": "Comment", "service": "Device State", "housemode": "House Mode", "weekday": "Weekday", "sun": "Sunrise/Sunset", "trange": "Date/Time", "interval": "Interval", "ishome": "Geofence", "reload": "Luup Reloaded", "grpstate": "Group State", "var": "Expression Value", "group": "Group" }; /* Note: default true for the following: hold, pulse, latch */ var condOptions = { "group": { sequence: true, duration: true, repeat: true }, "service": { sequence: true, duration: true, repeat: true }, "housemode": { sequence: true, duration: true, repeat: true }, "weekday": { }, "sun": { sequence: true }, "trange": { }, "interval": { pulse: false, latch: false }, "ishome": { sequence: true, duration: true }, "reload": { }, "grpstate": { sequence: true, duration: true, repeat: true }, "var": { sequence: true, duration: true, repeat: true } }; var weekDayName = [ '?', 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat' ]; var monthName = [ '?', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ]; var opName = { "bet": "between", "nob": "not between", "after": "after", "before": "before" }; var houseModeName = [ '?', 'Home', 'Away', 'Night', 'Vacation' ]; var inttypes = { "ui1": { min: 0, max: 255 }, "i1": { min: -128, max: 127 }, "ui2": { min: 0, max: 65535 }, "i2": { min: -32768, max: 32767 }, "ui4": { min: 0, max: 4294967295 }, "i4": { min: -2147483648, max: 2147483647 }, "int": { min: -2147483648, max: 2147483647 } }; var serviceOps = [ { op: '=', desc: 'equals', args: 1, optional: 1 }, { op: '<>', desc: 'not equals', args: 1, optional: 1 }, { op: '<', desc: '<', args: 1, numeric: 1, }, { op: '<=', desc: '<=', args: 1, numeric: 1, }, { op: '>', desc: '>', args: 1, numeric: 1, }, { op: '>=', desc: '>=', args: 1, numeric: 1, }, { op: 'bet', desc: 'between', args: 2, numeric: 1, format: "%1 and %2" }, { op: 'nob', desc: 'not between', args: 2, numeric: 1, format: "%1 and %2" }, { op: 'starts', desc: 'starts with', args: 1, }, { op: 'notstarts', desc: 'does not start with', args: 1, }, { op: 'ends', desc: 'ends with', args: 1, }, { op: 'notends', desc: 'does not end with', args: 1, }, { op: 'contains', desc: 'contains', args: 1, }, { op: 'notcontains', desc: 'does not contain', args: 1, }, { op: 'in', desc: 'in', args: 1 }, { op: 'notin', desc: 'not in', args: 1 }, { op: 'istrue', desc: 'is TRUE', args: 0, nocase: false }, { op: 'isfalse', desc: 'is FALSE', args: 0, nocase: false }, { op: 'isnull', desc: 'is NULL', args: 0, nocase: false }, { op: 'change', desc: 'changes', args: 2, format: "from %1 to %2", optional: 2, blank: "(any)" }, { op: 'update', desc: 'updates', args: 0, nocase: false } ]; var varRefPattern = /^\{([^}]+)\}\s*$/; var notifyMethods = [ { id: "", name: "Vera-native" } , { id: "SM", name: "SMTP Mail", users: false, extra: [ { id: "recipient", label: "Recipient(s):", placeholder: "blank=default recipient; comma-separate multiple", optional: true }, { id: "subject", label: "Subject:", placeholder: "blank=this ReactorSensor's name", optional: true } ], config: { name: "SMTPServer" } } , { id: "PR", name: "Prowl", users: false, requiresUnsafeLua: true, extra: [ { id: "priority", label: "Priority:", type: "select", default: "0", values: [ "-2=Very low", "-1=Low", "0=Normal", "1=High", "2=Emergency" ] } ], config: { name: "ProwlAPIKey" } } , { id: "PO", name: "Pushover", users: false, requiresUnsafeLua: true, extra: [ { id: "title", label: "Message Title", placeholder: "blank=this ReactorSensor's name", default: "", optional: true }, { id: "podevice", label: "Device:", placeholder: "optional", default: "", optional: true }, { id: "priority", label: "Priority:", type: "select", default: "0", values: [ "-2=Very low", "-1=Low", "0=Normal", "1=High" ] }, /* 2=Emergency doesn't seem to work, no alert is received 2020-09-23 */ { id: "sound", label: "Sound:", Xtype: "select", default: "", optional: true, placeholder: "blank=device default; select from list or enter your own value", datalist: [ "=(device default)", "none=(none/silent)", "vibrate=(vibrate only)", "pushover=Pushover", "bike=Bike", "bugle=Bugle", "cashregister=Cash Register", "classical=Classical", "cosmic=Cosmic", "falling=Falling", "gamelan=Gamelan", "incoming=Incoming", "intermission=Intermission", "magic=Magic", "mechanical=Mechanical", "pianobar=Piano Bar", "siren=Siren", "spacealarm=Space Alarm", "tugboat=Tug Boat", "alien=Alien Alarm (long)", "climb=Climb (long)", "persistent=Persistent (long)", "echo=Pushover Echo (long)", "updown=Up Down (long)" ] }, { id: "token", label: "Pushover Token:", placeholder: "blank=from Reactor config", default:"", optional: true } ], config: { name: "PushoverUser" } } , { id: "SD", name: "Syslog", users: false, extra: [ { id: "hostip", label: "Syslog Server IP:", placeholder: "Host IP4 Address", validpattern: "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$" }, { id: "facility", label: "Facility:", type: "select", default: "23", values: [ "0=kern","1=user","2=mail","3-daemon","4=auth","5=syslog","6=lp","7=news","8=uucp","9=clock","10=security","11=FTP","12=NTP","13=audit","14=alert","16=local0","17=local1","18=local2","19=local3","20=local4","21=local5","22=local6","23=local7" ] }, { id: "severity", label: "Severity:", type: "select", default: "5", values: [ "0=emerg","1=alert","2=crit","3=err","4=warn","5=notice","6=info","7=debug" ] } ] } , { id: "UU", name: "User URL", users: false, requiresUnsafeLua: true, extra: [ { id: "url", label: "URL:", type: "textarea", placeholder: "URL", validpattern: "^https?://", default: "http://localhost/alert?message={message}", fullwidth: true } ] } , { id: "VA", name: "VeraAlerts" } , { id: "VT", name: "VeraTelegram", users: false, extra: [ { id: "imageurl", label: "Image URL:", type: "textarea", placeholder: "URL", validpattern: "^https?://", default: "", optional: true, fullwidth: true }, { id: "videourl", label: "Video URL:", type: "textarea", placeholder: "URL", validpattern: "^https?://", default: "", optional: true, fullwidth: true }, { id: "chatid", label: "Chat ID:", default: "", optional: true }, { id: "disablenotification", label: "Disable Notification:", type: "select", values: [ "False=No", "True=Yes" ] } ] } ]; var msgUnsavedChanges = "You have unsaved changes! Press OK to save them, or Cancel to discard them."; var msgGroupIdChange = "Click to change group name"; var msgOptionsShow = "Show condition options"; var msgOptionsHide = "Hide condition options"; var msgRemoteAlert = "You appear to be using remote access for this session. Editing of ReactorSensor configurations via remote access is possible, but not recommended due to the latency and inconsistency of cloud connections and infrastructure. You may experience issues, particularly when saving large configurations. Using local access exclusively is strongly recommended. It is also a good idea to back up your ReactorSensors (using the Backup/Restore tab in the Reactor master device) prior to editing via remote access."; var NULLCONFIG = { conditions: {} }; /* Insert the header items */ /* Checkboxes, see https://codepen.io/VoodooSV/pen/XoZJme */ function header() { if ( 0 !== $( 'style#reactor-core-styles' ).length ) return; /* Load material design icons */ var $head = $( 'head' ); $head.append(''); $head.append( '\ '); if ( isALTUI ) { $head.append( '' ); } else { /* Vera */ $head.append( '' ); } } /* Return footer */ function footer() { var html = ''; html += '
'; html += '
Find Reactor useful? Please consider a small one-time donation to support this and my other plugins on my web site. I am grateful for any support you choose to give!
'; html += '
Reactor ver ' + pluginVersion + ' © 2018,2019,2020 Patrick H. Rigney,' + ' All Rights Reserved. Please check out the online documentation' + ' and community forums for support.
'; try { html += '
' + navigator.userAgent + '
'; } catch( e ) {} return html; } function checkRemoteAccess() { // return !isOpenLuup && null === api.getDataRequestURL().match( /^https?:\/\/(\d+)\.(\d+)\.(\d+)\.(\d+)/ ); return false; /* LATER 3.5 2019-12-02... not yet, let's see how other var changes work out */ } /* Create an ID that's functionally unique for our purposes. */ function getUID( prefix ) { /* Not good, but good enough. */ var newx = Date.now() - 1529298000000; if ( newx <= lastx ) newx = lastx + 1; lastx = newx; return ( prefix === undefined ? "" : prefix ) + newx.toString(36); } function isEmpty( s ) { return undefined === s || null === s || "" === s || ( "string" === typeof( s ) && null !== s.match( /^\s*$/ ) ); } function quot( s ) { return JSON.stringify( s ); } /* Remove special characters that disrupt JSON processing on Vera (dkjson 1.2 in particular */ /* Ref http://dkolf.de/src/dkjson-lua.fsl/home (see 1.2 comments) */ /* Ref https://docs.microsoft.com/en-us/openspecs/ie_standards/ms-es3/def92c0a-e69f-4e5e-8c5e-9f6c9e58e28b */ function purify( s ) { return "string" !== typeof(s) ? s : s.replace(/[\x00-\x1f\x7f-\x9f\u2028\u2029]/g, ""); /* or... s.replace( /[\u007F-\uFFFF]/g, function(ch) { return "\\u" + ("0000"+ch.charCodeAt(0).toString(16)).substr(-4); } ) */ } function hasAnyProperty( obj ) { // assert( "object" === typeof( obj ); if ( "object" === typeof( obj ) ) { for ( var p in obj ) { if ( obj.hasOwnProperty( p ) ) return true; } } return false; } /* Find a value in an array using a function to match; returns value, not index. */ function arrayFindValue( arr, func, start ) { var l = arr.length; for ( var k=(start || 0); k' ).val( val ).text( txt || ( val + '? (missing)' ) ); $mm.addClass( "tberror" ).append( $opt ); } val = $opt.val(); /* actual value now */ $mm.val( val ); return val; } /** getWiki - Get (as jQuery) a link to Wiki for topic */ function getWiki( where ) { var $v = $( '', { "class": "tbdocslink", "alt": "Link to documentation for topic", "title": "Link to documentation for topic", "target": "_blank", "href": _DOCURL + String(where || "") } ); $v.append( 'help_outline' ); return $v; } /* Return value or default if undefined */ function coalesce( v, d ) { return ( null === v || undefined === v ) ? d : v; } /* Evaluate input string as integer, strict (no non-numeric chars allowed other than leading/trailing whitespace, empty string fails). */ function getInteger( s ) { s = String(s).trim().replace( /^\+/, '' ); /* leading + is fine, ignore */ if ( s.match( /^-?[0-9]+$/ ) ) { return parseInt( s ); } return NaN; } /* Like getInteger(), but returns dflt if no value provided (blank/all whitespace) */ function getOptionalInteger( s, dflt ) { if ( /^\s*$/.test( String(s) ) ) { return dflt; } return getInteger( s ); } function getDeviceFriendlyName( dev, devobj ) { if ( -1 === dev ) return '(self)'; devobj = devobj || api.getDeviceObject( dev ); if ( ! devobj ) { console.log( "getDeviceFriendlyName() dev=(" + typeof(dev) + ")" + String(dev) + ", devobj=(" + typeof(devobj) + ")" + String(devobj) + ", returning false" ); return false; } return String(devobj.name) + " (#" + String(devobj.id) + ")"; } /* Get parent state */ function getParentState( varName, myid ) { var me = api.getDeviceObject( myid || api.getCpanelDeviceId() ); return api.getDeviceState( me.id_parent || me.id, "urn:toggledbits-com:serviceId:Reactor", varName ); } /* Set parent state */ function setParentState( varName, val, myid ) { var me = api.getDeviceObject( myid || api.getCpanelDeviceId() ); return api.setDeviceStatePersistent( me.id_parent || me.id, "urn:toggledbits-com:serviceId:Reactor", varName, val ); } function checkUpdate() { if ( isOpenLuup ) { return Promise.resolve( false ); } return new Promise( function( resolve, reject ) { $.ajax({ url: api.getDataRequestURL(), data: { id: "lr_Reactor", action: "updateplugin", r: Math.random() }, dataType: "json", timeout: 15000, cache: false }).fail( function( /* jqXHR, textStatus, errorThrown */ ) { reject(); }).done( function( data ) { if ( !data.status ) { reject( data.message ); return; } var newest = false; for ( var j=0; j= 1620345600000 ) { // 2021-05-07T00:00:00Z rel.published_at = pubtime; if ( !newest || pubtime > newest.published_at ) { newest = rel; } } } } /* Now see if newest is not current */ if ( newest ) { var st = getParentState( "grelease", false ) || ""; var r = st.split( /\|/ ); if ( r.length > 0 && r[0] == String(newest.id) ) { /* Installed version is current version */ newest = false; } } resolve( newest ); }); }); } /* Get data for this instance */ function getInstanceData( myid ) { myid = myid || api.getCpanelDeviceId(); iData[ myid ] = iData[ myid ] || {}; return iData[ myid ]; } /* Generate an inline checkbox. */ function getCheckbox( id, value, label, classes, help ) { var $div = $( '
' ); if ( isALTUI ) { $div.removeClass().addClass( 'form-check' ); $('').attr( { type: 'checkbox', id: id } ) .val( value ) .addClass( 'form-check-input' ) .addClass( classes || "" ) .appendTo( $div ); $('').attr( 'for', id ) .addClass( 'form-check-label' ) .html( label ) .appendTo( $div ); } else { $( '' ).attr( 'id', id ).val( value ) .addClass( classes || "" ) .appendTo( $div ); $( '' ).attr( 'for', id ).html( label ) .appendTo( $div ); } if ( help ) { getWiki( help ).appendTo( $div ); } return $div; } /* Generate an inline radio button */ function getRadio( name, ix, value, label, classes ) { var $div; if ( isALTUI ) { $div = $( '
' ).addClass( 'form-check' ); $('').attr( { type: 'radio', id: name + ix, name: name } ) .val( value ) .addClass( 'form-check-input' ) .addClass( classes || "" ) .appendTo( $div ); $('').attr( 'for', name + ix ) .addClass( 'form-check-label' ) .html( label ) .appendTo( $div ); } else { $div = $( '' ) .html( label ); $( '' ) .attr( { id: name+ix, name: name } ) .val( value ) .addClass( classes || "" ) .prependTo( $div ); } return $div; } /** * ALTUI: Inconsistencies between versions of UI7, and intra- and inter-ALTUI, * have to be resolved with some logic. */ function hideModal() { if ( api.hideMessagePopup ) { api.hideMessagePopup(); } else { /* Sigh. Do both on ALTUI, in case amg0 fixes his dialogs some day. */ if ( isALTUI ) { $( 'div#showMessagePopup' ).modal( 'hide' ); } $( 'div#myModal' ).modal( 'hide' ); } } /** * ALTUI doesn't implement on_cpanel_close event. Use tab behavior. * Pass any element on the current tab. */ function captureControlPanelClose( $el ) { if ( isALTUI ) { /** On ALTUI, we break plugin encapsulation by necessity and use * its tab handler to help us. */ var paneId = $el.closest( '.tab-pane' ).attr( 'id' ); $( 'a[href="#' + paneId + '"]' ).off( 'hide.bs.tab.reactor' ) .on( 'hide.bs.tab.reactor', function() { console.log("captureControlPanelClose() ALTUI module tab hide handler"); onBeforeCpanelClose(); }); $( 'a[href="#altui-toggle-control-panel"]' ).off( 'hide.bs.tab.reactor' ) .on( 'hide.bs.tab.reactor', function() { console.log("ALTUI control panel hide handler"); onBeforeCpanelClose(); }); } else { api.registerEventHandler('on_ui_cpanel_before_close', ReactorSensor, 'onBeforeCpanelClose'); } } /* Load configuration data. As of 3.5, we do not do any updates here. */ function loadConfigData( myid ) { var me = api.getDeviceObject( myid ); if ( ! ( me && deviceType === me.device_type ) ) { throw "Device " + String(myid) + " not found or incorrect type"; } // PHR??? Dynamic false needs more testing. Save/update of local/lustatus should be sufficient /* Empty configs are not allowed, but happen when the Vera UI gets wildly out of sync with Vera, which has happened increasingly since 7.29. */ var s = api.getDeviceState( myid, serviceId, "cdata" /* , { dynamic: false } */ ) || ""; if ( isEmpty( s ) ) { console.log( "ReactorSensor " + myid + ": EMPTY DATA" ); alert( 'Reactor has detected that the Vera UI may be badly out of sync with the Vera itself. To remedy this, please (1) reload Luup or reboot your Vera, and then (2) do a "hard-refresh" of your browser (refresh with cache flush). Do not edit any devices or do anything else until this issue has been remedied.' ); throw "empty configuration"; } else if ( "###" === s ) { alert( 'Please go back out to the device list and make sure this ReactorSensor is ENABLED before re-entering configuration.' ); throw "reset configuration"; } var cdata; try { cdata = JSON.parse( s ); /* Old Luup's json library doesn't support __jsontype metadata, so fix up empty objects, which it renders as empty arrays. */ if ( cdata.variables && Array.isArray( cdata.variables ) && cdata.variables.length == 0 ) { console.log("Fixing cdata.variables from array to object"); cdata.variables = {}; } if ( cdata.activities && Array.isArray( cdata.activities ) && cdata.activities.length == 0 ) { console.log("Fixing cdata.activities from array to object"); cdata.activities = {}; } } catch (e) { console.log("Unable to parse cdata: " + String(e)); throw e; } /* Special version check */ if ( ( cdata.version || 0 ) > _CDATAVERSION ) { alert("This ReactorSensor configuration is an unsupported format/version " + String( cdata.version ) + " for this version of Reactor (" + pluginVersion + " " + _CDATAVERSION + "). If you've downgraded Reactor from a later " + "version, you need to restore a backup of this ReactorSensor's configuration made " + "from the earlier version."); console.log("The configuration for this ReactorSensor is an unsupported format/version (" + String( cdata.version ) + "). Upgrade Reactor or restore an older config from backup."); throw "Incompatible configuration format/version"; } /* Check for upgrade tasks from prior versions */ delete cdata.undefined; if ( undefined === cdata.variables ) { /* Fixup v2 */ cdata.variables = {}; } if ( undefined === cdata.activities ) { cdata.activities = {}; } if ( undefined === cdata.conditions.root ) { var root = { id: "root", name: api.getDeviceObject( myid ).name, type: "group", operator: "and", conditions: [] }; cdata.conditions = { root: root }; } /* Update device */ cdata.device = myid; /* Store config on instance data */ var d = getInstanceData( myid ); d.cdata = cdata; delete d.ixCond; /* Remove until needed/rebuilt */ configModified = false; return cdata; } /* Get configuration; load if needed */ function getConfiguration( myid, force ) { myid = myid || api.getCpanelDeviceId(); var d = getInstanceData( myid ); if ( force || ! d.cdata ) { try { loadConfigData( myid ); console.log("getConfiguration(): loaded config serial " + String(d.cdata.serial) + ", timestamp " + String(d.cdata.timestamp)); } catch ( e ) { console.log("getConfiguration(): can't load config for "+myid+": "+String(e)); console.log(e); return false; } } else { console.log("getConfiguration(): returning cached config serial " + String(d.cdata.serial)); } return d.cdata; } /* Get condition index; build if needed (used by Status and Condition tabs) */ function getConditionIndex( myid ) { var d = getInstanceData( myid ); if ( undefined === d.ixCond ) { var cf = getConfiguration( myid ); d.ixCond = {}; var makeix = function( grp, level ) { d.ixCond[grp.id] = grp; grp.__depth = level; /* assigned to groups only */ for ( var ix=0; ix<(grp.conditions || []).length; ix++ ) { grp.conditions[ix].__parent = grp; grp.conditions[ix].__index = ix; d.ixCond[grp.conditions[ix].id] = grp.conditions[ix]; if ( "group" === ( grp.conditions[ix].type || "group" ) ) { makeix( grp.conditions[ix], level+1 ); } } }; makeix( cf.conditions.root || {}, 0 ); } return d.ixCond; } function getConditionStates( myid ) { myid = myid || api.getCpanelDeviceId(); var s = api.getDeviceState( myid, serviceId, "cstate" ) || ""; var cstate = {}; if ( ! isEmpty( s ) ) { try { cstate = JSON.parse( s ); return cstate; } catch (e) { console.log("cstate cannot be parsed: " + String(e)); } } else { console.log("cstate unavailable"); } /* Return empty cstate structure */ return { vars: {} }; } /* Generic filter for DOtraverse to return groups only */ function isGroup( node ) { return "group" === ( node.type || "group" ); } /* Traverse - pre-order */ function DOtraverse( node, op, args, filter ) { if ( node ) { if ( ( !filter ) || filter( node ) ) { op( node, args ); } if ( "group" === ( node.type || "group" ) ) { var l = node.conditions ? node.conditions.length : 0; for ( var ix=0; ix' + " ERROR! The Reactor plugin core version and UI version do not agree." + " This may cause errors or corrupt your ReactorSensor configuration." + " Please hard-reload your browser and try again " + ' (how?).' + " If you have installed hotfix patches, you may not have successfully installed all required files." + " Expected " + String(_UIVERSION) + " got " + String(s) + ".
" ); return false; } if ( undefined === Promise ) { alert( "Warning! The browser you are using does not support features required by this interface. The recommended browsers are Firefox, Chrome, Safari, and Edge. If you are using a modern version of one of these browsers and getting this message, please report to rigpapa via the Vera Community forums." ); return false; } if ( isALTUI ) { console.log("initModule() supported ALTUI versions:",_MIN_ALTUI_VERSION,"to",_MAX_ALTUI_VERSION); var validALTUI = false; var av; var av_range = String(_MIN_ALTUI_VERSION) + " to " + String(_MAX_ALTUI_VERSION); if ( "undefined" !== typeof ALTUI_revision ) { av = ALTUI_revision.match( /: *(\d+)/ ); if ( null !== av ) { av = parseInt( av[1] ); console.log("initModule(): ALTUI release",av,"from ALTUI_revision"); if ( !isNaN( av ) && av >= _MIN_ALTUI_VERSION && av <= _MAX_ALTUI_VERSION ) { validALTUI = true; } } } if ( !validALTUI ) { alert("The running version of ALTUI has not been confirmed to be compatible with this version of the Reactor UI and is therefore not supported. Incompatibilities may cause loss of functionality or errors that result in data/configuration loss, and it is recommended that you up/downgrade to a compatible version of ALTUI before continuing.\n\nSupported versions are: " + av_range + ".\nYou are running " + String( ALTUI_revision )); return false; } try { var jq = String($.fn.jquery).split('.'); jq = parseInt( jq[0] ) * 100 + parseInt( jq[1] ); if ( jq >= 306 ) { alert("This version of the Reactor UI does not support the version of jQuery you are using on this system. Typically, the jQuery version is determined by the ALTUI version being used, and if that version of ALTUI is supported, its default jQuery is as well. Overriding ALTUI's default to a higher version can result in incompatibilities that could result in configuration corruption.\n\nNot supported: jQuery "+String($.fn.jquery)); return false; } } catch( e ) { console.log("initModule() error checking jQuery version:",e); } } /* Load ACE. */ s = getParentState( "UseACE", myid ) || "1"; if ( "1" === s && ! window.ace ) { s = getParentState( "ACEURL" ) || "https://cdnjs.cloudflare.com/ajax/libs/ace/1.4.12/ace.js"; $( "head" ).append( '' ); } /* Initialize for instance */ console.log("initModule() initializing instance data for " + myid); iData[myid] = iData[myid] || {}; getConfiguration( myid ); /* Force this false every time, and make the status panel change it. */ inStatusPanel = false; return true; } /** * Return list of devices sorted alpha by room sorted alpha with "no room" * forced last. Store this for future returns; deferred effort/memory use. */ function getSortedDeviceList() { if ( roomsByName ) return roomsByName; var myid = api.getCpanelDeviceId(); var devices = api.cloneObject( api.getListOfDevices() ); var noroom = { "id": 0, "name": "No Room", "devices": [] }; var rooms = [ noroom ]; var roomIx = {}; roomIx[String(noroom.id)] = noroom; var dd = devices.sort( function( a, b ) { if ( a.id == myid ) return -1; if ( b.id == myid ) return 1; if ( a.name.toLowerCase() === b.name.toLowerCase() ) { return a.id < b.id ? -1 : 1; } return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1; }); var l = dd.length; for (var i=0; i b.name.toLowerCase() ? 1 : -1; } ); return roomsByName; } /* zero-fill */ function fill( s, n, p ) { if ( "string" !== typeof(s) ) { s = String(s); } p = p || "0"; var l = n - s.length; while ( l-- > 0 ) { s = p + s; } return s; } /* Format timestamp to string (models strftime) */ function ftime( t, fmt ) { var dt = new Date(); dt.setTime( t ); var str = fmt || dateFormat; str = str.replace( /%(.)/g, function( m, p ) { switch( p ) { case 'Y': return String( dt.getFullYear() ); case 'm': return fill( dt.getMonth()+1, 2 ); case 'd': return fill( dt.getDate(), 2 ); case 'H': return fill( dt.getHours(), 2 ); case 'I': var i = dt.getHours() % 12; if ( 0 === i ) i = 12; return fill( i, 2 ); case 'p': return dt.getHours() < 12 ? "AM" : "PM"; case 'M': return fill( dt.getMinutes(), 2 ); case 'S': return fill( dt.getSeconds(), 2 ); case '%': return '%'; case 'T': return ftime( t, "%H:%M:%S" ); case 'F': return ftime( t, "%Y-%m-%d" ); default: return m; } }); return str; } function textDateTime( y, m, d, hh, mm, isEnd ) { hh = parseInt( hh || "0" ); mm = parseInt( mm || "0" ); var tstr = ( hh < 10 ? '0' : '' ) + hh + ':' + ( mm < 10 ? '0' : '' ) + mm; /* Possible forms are YMD, MD, D with time, or just time */ if ( isEmpty( m ) ) { if ( isEmpty( d ) ) { return tstr; } return tstr + " on day " + d + " of each month"; } m = parseInt( m ); return monthName[m] + ' ' + d + ( isEmpty( y ) ? '' : ' ' + y ) + ' ' + tstr; } /** * Convert Lua timestamp (secs since Epoch) to text; if within 24 hours, * show time only. */ function shortLuaTime( dt ) { if ( ( dt || 0 ) === 0 ) { return ""; } var dtms = dt * 1000; var ago = Date.now() - dtms; if ( ago < 86400000 ) { return ftime( dtms, timeFormat ); } return ftime( dtms, dateFormat + " " + timeFormat ); } /** * Delete a state variable (with callback). Note that failing to delete a * variable isn't fatal, as we get ample opportunities to try again later. */ function deleteStateVariable( devnum, serviceId, variable, fnext ) { console.log("deleteStateVariable: deleting " + devnum + "." + serviceId + "/" + variable); $.ajax({ url: api.getDataRequestURL(), data: { id: "variableset", DeviceNum: devnum, serviceId: serviceId, Variable: variable, Value: "", r: Math.random() }, dataType: "text", cache: false, timeout: 5000 }).fail( function( /* jqXHR, textStatus, errorThrown */ ) { console.log( "deleteStateVariable: failed, maybe try again later" ); }).always( function() { console.log("deleteStateVariable: finished, calling next"); if ( fnext ) { fnext(); } }); } /** * Attempt to remove state variables that are no longer used. */ function clearUnusedStateVariables( myid, cdata ) { if ( isOpenLuup ) return; /* Can't delete state vars on openLuup */ var ud = api.getUserData(); var dx = api.getDeviceIndex( myid ); var deletes = []; var myinfo = ud.devices[dx]; if ( undefined == myinfo ) return; /* N.B. ixCond will be present in the condition editor only */ var ixCond = getConditionIndex( myid ); var l = myinfo.states.length; for ( var ix=0; ix= expire ) { if (dlg) { hideModal(); } reject( "timeout" ); } else { setTimeout( tryAlive, 2000 ); } } }).fail( function() { if ( Date.now() >= expire ) { if (dlg) { hideModal(); } reject( "timeout" ); } else { if ( ! dlg ) { api.showCustomPopup( msg || "Waiting for Luup ready before operation...", { autoHide: false, category: 3 } ); dlg = true; } setTimeout( tryAlive, 5000 ); } }); } tryAlive(); }); } function saveConfiguration( myid, successFunc, failFunc ) { /* Save to persistent state */ myid = myid || api.getCpanelDeviceId(); var cdata = getConfiguration( myid ); cdata.timestamp = Math.floor( Date.now() / 1000 ); cdata.serial = ( cdata.serial || 0 ) + 1; cdata.device = myid; console.log("saveConfiguration(): saving #" + myid + " config serial " + String(cdata.serial) + ", timestamp " + String(cdata.timestamp)); waitForReloadComplete( "Waiting for system ready before saving configuration..." ).then( function() { console.log("saveConfiguration() writing cdata"); var jsstr = JSON.stringify( cdata, function( k, v ) { return ( k.match( /^__/ ) || v === null ) ? undefined : purify(v); } ); api.setDeviceStateVariablePersistent( myid, serviceId, "cdata", jsstr, { 'onSuccess' : function() { if ( ! isALTUI ) { api.setDeviceState( myid, serviceId, "cdata", jsstr ); /* force local/lu_status */ } configModified = false; updateSaveControls(); console.log("saveConfiguration(): successful"); successFunc && successFunc(); }, 'onFailure' : function() { console.log("saveConfiguration(): FAILED"); failFunc && failFunc(); } } ); /* setDeviceStateVariable */ }); /* then */ } /** * Handle save click: save the current configuration. */ function handleSaveClick( ev, fnext, fargs ) { var myid = api.getCpanelDeviceId(); var cdata = getConfiguration( myid ); $( "button.revertconf" ).prop( "disabled", true ); $( "button.saveconf" ).text("Wait...").prop( "disabled", true ); saveConfiguration( myid, function() { updateSaveControls(); configModified = false; fnext && fnext.apply( null, fargs ); if ( cdata.__reloadneeded ) { delete cdata.__reloadneeded; api.showCustomPopup( "Reloading Luup...", { autoHide: false, category: 3 } ); setTimeout( function() { api.performActionOnDevice( 0, "urn:micasaverde-com:serviceId:HomeAutomationGateway1", "Reload", { actionArguments: { Reason: "Reactor saved config needs reload" } } ); setTimeout( function() { waitForReloadComplete().catch( function(reason) { /* Errors here don't matter */ }).finally( function() { hideModal(); }); }, 5000 ); }, 5000 ); } else { clearUnusedStateVariables( myid, cdata ); } }, function() { alert('There was a problem saving the configuration. Vera/Luup may have been restarting. Please try saving again in a moment.'); updateSaveControls(); fnext && fnext.apply( null, fargs ); }); } /** * Check for unsaved changes, save... */ function checkUnsaved( myid ) { if ( configModified ) { if ( confirm( msgUnsavedChanges ) ) { handleSaveClick( undefined ); } else { /* Discard unsaved config */ var d = getInstanceData( myid ); delete d.cdata; delete d.ixCond; } } configModified = false; } /* Closing the control panel. */ onBeforeCpanelClose = function() { console.log( "onBeforeCpanelClose" ); if ( configModified ) { if ( confirm( msgUnsavedChanges ) ) { handleSaveClick( undefined ); } } configModified = false; clearModule(); }; /** * Handle revert button click: restore setting to last saved and redisplay. */ function handleRevertClick( ev ) { if ( ! confirm( "Discard changes and revert to last saved configuration?" ) ) { return; } var myid = api.getCpanelDeviceId(); getConfiguration( myid, true ); configModified = false; updateSaveControls(); /* Be careful about which tab we're on here. */ /* ??? when all tabs are modules, module.redraw() is a one-step solution */ var ctx = $( ev.currentTarget ).closest('div.reactortab').attr('id'); if ( ctx === "tab-vars" ) { redrawVariables(); } else if ( ctx === "tab-conds" ) { CondBuilder.redraw( myid ); } else if ( ctx === "tab-actions" ) { redrawActivities(); } else { alert("OK, I did the revert, but now I'm lost. Go to the Status tab, and then come back to this tab."); } } function conditionValueText( v, forceNumber ) { if ( "number" === typeof(v) ) return v; v = String(v).trim(); if ( v.match( varRefPattern ) ) return v; if ( forceNumber ) { var n; if ( v.match( /^[+-]?[0-9]+$/ ) ) { n = parseInt( v ); } else { n = parseFloat( v ); } if ( isNaN( n ) ) return JSON.stringify( v ) + "(invalid)"; return String( n ); } return JSON.stringify( v ); } function makeConditionDescription( cond ) { if ( cond === undefined ) { return "(undefined)"; } var str = "", t, k; switch ( cond.type || "group" ) { case 'group': str = String(cond.name || cond.id); break; case 'service': case 'var': if ( "var" === cond.type ) { str += cond.var || "(undefined)"; } else { t = getDeviceFriendlyName( cond.device ); str += t ? t : '#' + cond.device + ' ' + ( cond.devicename || cond.deviceName || "name unknown" ) + ' (missing)'; str += ' ' + ( cond.variable || "?" ); } t = arrayFindValue( serviceOps, function( v ) { return v.op === cond.operator; } ); if ( !t ) { str += ' ' + cond.operator + '? ' + conditionValueText( cond.value ); } else { str += ' ' + (t.desc || t.op); if ( undefined === t.args || t.args > 0 ) { if ( t.args > 1 ) { var fmt = t.format || "%1,%2"; k = coalesce( cond.value, "" ).split( /,/ ); /* Remove trailing empties if they are optional */ while ( k.length > 0 && ( t.optional || 0 ) >= k.length && isEmpty( k[k.length-1] ) ) { k.pop(); } /* ??? FIXME -- Future pattern replacement loop. For now, never more than 2, so simple */ fmt = fmt.replace( '%1', k.length > 0 && !isEmpty( k[0] ) ? conditionValueText( k[0], t.numeric ) : (t.blank || '""' ) ); fmt = fmt.replace( '%2', k.length > 1 && !isEmpty( k[1] ) ? conditionValueText( k[1], t.numeric ) : (t.blank || '""' ) ); str += ' ' + fmt; } else { str += ' ' + conditionValueText( cond.value, t.numeric ); } } } if ( ( !t || 0 === ( t.numeric || 0 ) ) && 0 === coalesce( cond.nocase, 1 ) ) { str += ' (match case)'; } break; case "grpstate": t = getDeviceFriendlyName( cond.device ); str += t ? t : '#' + cond.device + ' ' + ( cond.devicename || cond.deviceName || "name unknown" ) + ' (missing)'; try { var devnum = -1 === ( cond.device || -1 ) ? api.getCpanelDeviceId() : cond.device; t = ( getConditionIndex( devnum ) || {} )[ cond.groupid ]; str += ' ' + ( t ? ( t.name || cond.groupid || "?" ) : ( ( cond.groupid || "?" ) + " (MISSING!)" ) ); } catch( e ) { str += ' ' + ( cond.groupid || "?" ) + ' (' + String(e) + ')'; } t = arrayFindValue( serviceOps, function( v ) { return v.op === cond.operator; } ); if ( t ) { str += ' ' + ( t.desc || t.op ); } else { str += ' ' + String(cond.operator) + '?'; } break; case 'comment': str = cond.comment; break; case 'housemode': t = ( cond.value || "" ).split( /,/ ); if ( cond.operator == "change" ) { str += "changes from "; if ( t.length > 0 && t[0] !== "" ) { str += houseModeName[t[0]] || t[0]; } else { str += "any mode"; } str += " to "; if ( t.length > 1 && t[1] !== "" ) { str += houseModeName[t[1]] || t[1]; } else { str += "any mode"; } } else { str += "is "; if ( t.length == 0 || t[0] === "" ) { str += "invalid"; } else { for ( k=0; k 0 ) { str = str + String(offs) + " mins after "; } str = str + ( names[k[1]] || k[1] ); return str; } } t = ( cond.value || "sunrise+0,sunset+0" ).split(/,/); str += sunrange( t[0] || "sunrise+0" ); if ( cond.operator == "bet" || cond.operator == "nob" ) { str += " and "; str += sunrange( t[1] || "sunset+0" ); } break; case 'trange': if ( opName[ cond.operator ] !== undefined ) { str += opName[ cond.operator ]; } else { str += cond.operator + '?'; } t = ( cond.value || "" ).split(/,/); str += ' ' + textDateTime( t[0], t[1], t[2], t[3], t[4], false ); if ( cond.operator !== "before" && cond.operator !== "after" ) { str += ' and ' + textDateTime( t[5], t[6], t[7], t[8], t[9], true ); } break; case 'interval': str += "every"; if ( cond.days > 0 ) { str += " " + String(cond.days) + " days"; } if ( cond.hours > 0 ) { str += " " + String(cond.hours) + " hours"; } if ( cond.mins > 0 ) { str += " " + String(cond.mins) + " minutes"; } if ( "condtrue" === ( cond.relto || "" ) ) { str += " (relative to "; str += makeConditionDescription( getConditionIndex()[ cond.relcond ] ); str += ")"; } else { if ( ! isEmpty( cond.basetime ) ) { t = cond.basetime.split(/,/); str += " (relative to "; if ( ! isEmpty( cond.basedate ) ) { var d = cond.basedate.split(/,/); try { var dt = new Date( parseInt( d[0] ), parseInt( d[1] - 1 ), parseInt( d[2] ), parseInt( t[0] ), parseInt( t[1] ), 0, 0 ); str += ftime( dt, dateFormat + " " + timeFormat ); } catch ( e ) { str += String( cond.basedate ) + ' ' + String( cond.basetime ); } } else { if ( t.length == 2 ) { str += t[0] + ":" + t[1]; } else { str += String( cond.basetime ); } } str += ")"; } } break; case 'ishome': t = ( cond.value || "" ).split(/,/); if ( "at" === cond.operator || "notat" === cond.operator ) { var desc = cond.operator == "at" ? " at " : " not at "; var uu = userIx[t[0]]; if ( undefined === uu ) { str += String(t[0]) + desc + " location " + String(t[1]); } else { var nn = uu.name || t[0]; if ( uu.tags && uu.tags[t[1]] ) { str += nn + desc + uu.tags[t[1]].name; } else { str += nn + desc + " location " + t[1]; } } } else { if ( t.length < 1 || t[0] == "" ) { str += cond.operator === "is not" ? "no user is home" : "any user is home"; } else { /* Replace IDs with names for display */ for ( k=0; k'); getSortedDeviceList().forEach( function( roomObj ) { var haveItem = false; var xg = $( '' ).attr( 'label', roomObj.name ); var l = roomObj.devices.length; for ( var j=0; j' ).val( devobj.id ).text( fn ? fn : '#' + String(devobj.id) + '?' ) ); } if ( haveItem ) { el.append( xg ); } }); el.prepend( $( '' ).val( "-1" ).text( "(this ReactorSensor)" ) ); el.prepend( $( '' ).val( "" ).text( "--choose device--" ) ); menuSelectDefaultInsert( el, val, "(missing) #" + String(val) + " " + String(name || "") ); return el; } /** * Update save/revert buttons (separate, because we use in two diff tabs */ function updateSaveControls() { var errors = $('.tberror'); var pos = $( window ).scrollTop(); $('button.saveconf').text("Save") .prop('disabled', ! ( configModified && errors.length === 0 ) ) .attr('title', errors.length === 0 ? "" : "Fix errors before saving"); $('button.revertconf').prop('disabled', !configModified); setTimeout( function() { $(window).scrollTop( pos ); }, 100 ); } /** *************************************************************************** * * S T A T U S * ** **************************************************************************/ function updateTime( condid, target, prefix, countdown, limit ) { var $el = $( 'span#' + idSelector(condid) + ".timer" ); if ( 0 === $el.length ) { console.log(condid+" not found, bye!"); return; } var now = Math.floor( Date.now() / 1000 + 0.5 ); var d; if ( countdown ) { /* Count down -- delta is (future) target to now */ d = target - now; if ( d < ( limit || 0 ) ) { $el.remove(); return; } } else { /* Count up -- delta is now since target */ d = now - target; if ( limit && d > limit ) { $el.remove(); return; } } var hh = Math.floor( d / 3600 ); d -= hh * 3600; var mm = Math.floor( d / 60 ); d -= mm * 60; d = (mm < 10 ? '0' : '') + String(mm) + ':' + (d < 10 ? '0' : '') + String(d); if ( 0 !== hh ) d = (hh < 10 ? '0' : '') + String(hh) + ':' + d; $el.text( prefix + ' ' + d ); setTimeout( function() { updateTime( condid, target, prefix, countdown, limit ); }, 500 ); } function getCondOptionDesc( cond ) { var condOpts = cond.options || {}; var condDesc = ""; if ( undefined !== condOpts.after ) { condDesc += ( ( condOpts.aftertime || 0 ) > 0 ? ' within ' + condOpts.aftertime + ' secs' : '' ) + ' after ' + makeConditionDescription( getConditionIndex()[ condOpts.after ] ) + ( 0 === ( condOpts.aftermode || 0 ) ? ' (which is still TRUE)' : '' ); } if ( ( condOpts.repeatcount || 0 ) > 1 ) { condDesc += " repeats " + condOpts.repeatcount + " times within " + ( condOpts.repeatwithin || 60 ) + " secs"; } else if ( ( condOpts.duration || 0 ) > 0 ) { condDesc += " for " + ( condOpts.duration_op === "lt" ? "less than " : "at least " ) + condOpts.duration + " secs"; } if ( ( condOpts.holdtime || 0 ) > 0 ) { condDesc += "; delay reset for " + condOpts.holdtime + " secs"; } if ( ( condOpts.pulsetime || 0 ) != 0 ) { condDesc += "; pulse for " + condOpts.pulsetime + " secs"; var pbo = condOpts.pulsebreak || 0; if ( pbo > 0 ) { condDesc += ", repeat after " + pbo + " secs"; pbo = condOpts.pulsecount || 0; if ( pbo > 0 ) { condDesc += ", up to " + pbo + " times"; } } } if ( ( condOpts.latch || 0 ) != 0 ) { condDesc += "; latching"; } return condDesc; } function getCondState( cond, currentValue, cstate, el ) { el.text( "" ); if ( cond.type !== "comment" && undefined !== currentValue ) { var cs = cstate[cond.id] || {}; var shortVal = String( currentValue ); el.attr( 'title', shortVal ); /* before we cut it */ if ( shortVal.length > 20 ) { shortVal = shortVal.substring( 0, 17 ) + '...'; } el.text( shortVal + ( currentValue === cs.laststate ? "" : ( cs.laststate ? " (true)" : " (false)" ) ) + ' as of ' + shortLuaTime( cs.statestamp ) ); if ( condOptions[ cond.type || "group" ].repeat && ( ( cond.options||{} ).repeatcount || 0 ) > 1 ) { if ( cs.repeats !== undefined && cs.repeats.length > 1 ) { var dtime = cs.repeats[ cs.repeats.length - 1 ] - cs.repeats[0]; el.append( " (last " + cs.repeats.length + " span " + dtime + " secs)" ); } } if ( ( cs.pulsecount || 0 ) > 0 ) { var lim = ( cond.options||{} ).pulsecount || 0; el.append( " (pulsed " + (cs.pulsecount < 1000 ? cs.pulsecount : ">999" ) + ( lim > 0 ? ( " of max " + lim ) : "" ) + " times)" ); } if ( cs.latched ) { el.append( ' (latched)' ); } /* Generate unique IDs for timers so that redraws will have different IDs, and the old timers will self-terminate. */ var id; if ( cs.laststate && cs.waituntil ) { id = getUID(); el.closest('div.cond').addClass('reactor-timing'); el.append( $('').attr( 'id', id ) ); (function( c, t, l ) { setTimeout( function() { updateTime( c, t, "; sustained", false, l ); }, 20 ); })( id, cs.statestamp, ( cond.options || {} ).duration ); } else if (cs.evalstate && cs.holduntil) { id = getUID(); el.closest('div.cond').addClass('reactor-timing'); el.append( $('').attr( 'id', id ) ); (function( c, t, l ) { setTimeout( function() { updateTime( c, t, "; reset delayed", true, l ); }, 20 ); })( id, cs.holduntil, 0 ); } else if ( cs.pulseuntil) { id = getUID(); el.closest('div.cond').addClass('reactor-timing'); el.append( $('').attr( 'id', id ) ); (function( c, t, l ) { setTimeout( function() { updateTime( c, t, "; pulse ", true, l ); }, 20 ); })( id, cs.pulseuntil, 0 ); } else { el.closest('div.cond').removeClass('reactor-timing'); } } } function handleStatusCondClick( ev ) { var $el = $( ev.target ); var $grp = $el.closest( 'div.reactorgroup' ); var $body = $( 'div.grpbody', $grp ); $grp.toggleClass( 're-grp-collapsed' ); $body.toggle( ! $grp.hasClass( 're-grp-collapsed' ) ); } function showGroupStatus( grp, container, cstate ) { var grpel = $( '\
\
??
\
\
\
\
' ); var title = 'Group: ' + (grp.name || grp.id ) + ( grp.disabled ? " (disabled)" : "" ) + " <" + grp.id + ">"; $( 'span.re-title', grpel ).text( title + getCondOptionDesc( grp ) + "; " ); $( '.condbtn', grpel ).text( (grp.invert ? "NOT " : "") + (grp.operator || "and" ).toUpperCase() ) .on( "click.reactor", handleStatusCondClick ); /* Highlight groups that are "true" */ if ( grp.disabled || "0" === api.getDeviceState( api.getCpanelDeviceId(), serviceId, "Enabled" ) ) { grpel.addClass( 'groupdisabled' ); } else { var gs = cstate[ grp.id ] || {}; getCondState( grp, gs.laststate, cstate, $( 'span.currentvalue', grpel ) ); if ( "undefined" === typeof gs.evalstate || null === gs.evalstate ) { grpel.addClass( "nostate" ); } else if ( gs.evalstate ) { grpel.addClass( "truestate" ); } } container.append( grpel ); grpel = $( 'div.grpcond', grpel ); var l = grp.conditions ? grp.conditions.length : 0; for ( var i=0; i').attr( 'id', cond.id ); var currentValue = ( cstate[cond.id] || {} ).lastvalue; var condType = condTypeName[ cond.type ] !== undefined ? condTypeName[ cond.type ] : cond.type; var condDesc = makeConditionDescription( cond ); switch ( cond.type ) { case 'service': case 'grpstate': case 'var': if ( -1 !== ( cond.device || -1 ) ) { row.toggleClass( "re-cond-error", ! api.getDeviceObject( cond.device ) ); } break; case 'weekday': if ( currentValue !== undefined && weekDayName[ currentValue ] !== undefined ) { currentValue = weekDayName[ currentValue ]; } break; case 'housemode': if ( currentValue !== undefined && houseModeName[ currentValue ] !== undefined ) { currentValue = houseModeName[ currentValue ]; } break; case 'sun': case 'trange': if ( currentValue !== undefined ) { currentValue = shortLuaTime( currentValue ); } break; case 'interval': currentValue = shortLuaTime( currentValue ); break; case 'ishome': var t = (currentValue || "").split( /,/ ); if ( "at" === cond.operator || "notat" === cond.operator ) { /* Nada */ } else { /* Replace IDs with names for display */ if ( t.length > 0 && t[0] !== "" ) { for ( var k=0; k' ).html( 'remove' ) ); row.append( $( '
' ).text( condType + ': ' + condDesc ) ); /* Append current value and condition state */ var el = $( '
' ); row.append( el ); getCondState( cond, currentValue, cstate, el ); /* Apply highlight for state */ if ( cond.type !== "comment" && undefined !== currentValue ) { var cs = cstate[cond.id] || {}; row.toggleClass( 'truestate', true === cs.evalstate ) .toggleClass( 'falsestate', true === cs.evalstate ); $( 'div.condind i', row ).text( cs.evalstate ? 'check' : 'clear' ); } grpel.append( row ); } } } /** * Update status display. */ function updateStatus( pdev ) { var el; var stel = $('div#reactorstatus'); if ( stel.length === 0 || !inStatusPanel ) { /* If not displayed, do nothing. */ return; } /* Get configuration data and current state */ var cdata = getConfiguration( pdev, true ); if ( undefined === cdata ) { stel.empty().text("An error occurred while attempting to fetch the configuration data. Luup may be reloading. Try again in a few moments."); console.log("cdata unavailable"); return; } var cstate = getConditionStates( pdev ); stel.empty(); var s = parseInt( api.getDeviceState( pdev, serviceId, "TestTime" ) || "0" ); if ( s && s > 0 ) { var tid = getUID( "clk" ); $('
').attr( 'id', tid ).text("Test Time is in effect!") .appendTo( stel ); var updateTestClock = function( fid, base ) { if ( !inStatusPanel ) return; var $f = $( 'div#' + fid + ".re-alertblock" ); if ( 1 === $f.length ) { var now = Math.floor( Date.now() / 1000 ); var offs = now - (parseInt( api.getDeviceState( pdev, serviceId, "tref" ) ) || now); var dt = new Date(); dt.setTime( ( base + offs ) * 1000 ); $f.text("Test Time is in effect! Test clock is " + dt.toLocaleString()); window.setTimeout( function() { updateTestClock(fid, base); }, 500 ); } }; updateTestClock( tid, s ); } var thm = api.getDeviceState( pdev, serviceId, "TestHouseMode" ) || "0"; if ( ! ( isEmpty(thm) || "0" === thm ) ) { $('
').text("Test House Mode is in effect!") .appendTo( stel ); } var vix = []; for ( var vn in ( cdata.variables || {} ) ) { if ( cdata.variables.hasOwnProperty( vn ) ) { var v = cdata.variables[vn]; vix.push( v ); } } if ( vix.length > 0 ) { vix.sort( function( a, b ) { var i1 = a.index || -1; var i2 = b.index || -1; if ( i1 === i2 ) { i1 = (a.name || "").toLowerCase(); i2 = (b.name || "").toLowerCase(); if ( i1 === i2 ) return 0; /* fall through */ } return ( i1 < i2 ) ? -1 : 1; }); var grpel = $( '
' ); grpel.append( '
Expressions
' ); var body = $( '
' ); grpel.append( body ); var l = vix.length; for ( var ix=0; ix' ); var vv = ((cstate.vars || {})[vd.name] || {}).lastvalue; if ( null === vv ) { vv = "(null)"; } else if ( "object" === typeof vv && "null" === vv.__type ) { vv = "( null )"; } else { try { vv = JSON.stringify(vv); } catch( e ) { vv = String( vv ); } } var ve = coalesce( vs.err, "" ); if ( vv && vv.length > 256 ) { vv = vv.substring( 0, 253 ) + "..."; } el.append( $('
').text( vd.name ) ); el.append( $('
').text( isEmpty( vd.expression ) ? "(no expression)" : vd.expression ) ); el.append( $('
').text( "" !== ve ? ve : vv ) ); if ( "" !== ve ) { el.addClass( 'tb-exprerr' ); } else if ( vs.changed ) { el.addClass( 'tb-valchanged' ); } body.append( el ); } stel.append( grpel ); } showGroupStatus( cdata.conditions.root, stel, cstate ); } function onUIDeviceStatusChanged( args ) { if ( !inStatusPanel ) { return; } var pdev = api.getCpanelDeviceId(); if ( args.id == pdev ) { if ( arrayFindValue( args.states || [], function( v ) { return null !== v.variable.match( /^(cdata|cstate|Tripped|Armed|Enabled|TestTime|TestHouseMode|LastLoad)$/i ); } ) ) { try { updateStatus( pdev ); } catch (e) { console.log( "Display update failed: " + String(e)); console.log( e ); } } } } function doStatusPanel() { console.log("doStatusPanel()"); /* Make sure changes are saved. */ var myid = api.getCpanelDeviceId(); checkUnsaved( myid ); if ( needsRestart && confirm( 'It is recommended that your ReactorSensor be restarted after setting or clearing the Test Time or House Mode. Press "OK" to restart it now, or "Cancel" to skip the restart.' ) ) { api.performActionOnDevice( myid, serviceId, "Restart", { actionArguments: {} } ); } needsRestart = false; if ( ! initModule() ) { return; } /* Standard header stuff */ header(); /* Our styles. */ if ( 0 === $('style#reactor-status-styles').length ) { $("head").append( ''); } api.setCpanelContent( '
Loading...
' ); inStatusPanel = true; /* Tell the event handler it's OK */ api.registerEventHandler('on_ui_deviceStatusChanged', ReactorSensor, 'onUIDeviceStatusChanged'); captureControlPanelClose( $('div#reactorstatus') ); try { $( `
If you are considering moving to Home Assistant or Hubitat, check out my Multi-Hub Reactor project. It's an evolution of Reactor for Vera that supports multiple home automation hubs including Vera, to ease your transition between them -- take your time moving devices from one platform to another, rather than trying to do a "fork lift upgrade" of your entire home automation system and devices at once. It also works with MQTT, giving you accecss to its universe of supported devices and integrations, including a ton of inexpensive WiFi devices. And yes, it even supports Ezlo hubs, too.
` ) .insertBefore( $( 'div#reactorstatus' ) ); updateStatus( myid ); checkUpdate().then( function( data ) { if ( data ) { $( '
' ) .text( 'An update for Reactor is available. The latest release is ' + data.name + '. Go to the About tab on the master device to install it.' ) .insertBefore( $( 'div#reactorstatus' ) ); } }); } catch ( e ) { inStatusPanel = false; /* stop updates */ console.log( e ); alert( e.stack ); } setTimeout( function() { clearUnusedStateVariables( myid, getConfiguration( myid ) ); }, 2000 ); } /** *************************************************************************** * * C O N D I T I O N S * ** **************************************************************************/ /** * The condition builder is encapsulated into its own module for "private" * implementation. It shares some global functions with other tab code, * but I'll clean this up as I go along and make everything more modular. */ var CondBuilder = (function( api, $ ) { var redrawGroup; /* fwd decl */ /** * Renumber group conditions. */ function reindexConditions( grp ) { var $el = $( 'div#' + idSelector( grp.id ) + '.cond-container.cond-group' ).children( 'div.cond-group-body' ).children( 'div.cond-list' ); var ixCond = getConditionIndex(); var ix = 0; grp.conditions.splice( 0, grp.conditions.length ); /* empty in place */ $el.children().each( function( n, row ) { var id = $( row ).attr( 'id' ); var obj = ixCond[ id ]; if ( obj ) { // console.log("reindexConditions(" + grp.id + ") " + id + " is now " + ix); $( row ).removeClass( 'level' + String( obj.__depth || 0 ) ).removeClass( 'levelmod0 levelmod1 levelmod2 levelmod3' ); grp.conditions[ix] = obj; obj.__parent = grp; obj.__index = ix++; if ( "group" == ( obj.type || "group" ) ) { obj.__depth = grp.__depth + 1; $( row ).addClass( 'level' + obj.__depth ).addClass( 'levelmod' + (obj.__depth % 4) ); } } else { /* Not found. Remove from UI */ $( row ).remove(); } }); } /** * Remove all properies on condition except those in the exclusion list. * The id and type properties are always preserved. */ function removeConditionProperties( cond, excl ) { var elist = (excl || "").split(/,/); var emap = { id: true, type: true, options: true }; /* never remove these */ for ( var ix=0; ix' ); /* Create a list of variables by index, sorted. cdata.variables is a map/hash, not an array */ var cdata = getConfiguration(); var vix = []; for ( var vn in ( cdata.variables || {} ) ) { if ( cdata.variables.hasOwnProperty( vn ) ) { var v = cdata.variables[vn]; vix.push( v ); } } vix.sort( function( a, b ) { var i1 = a.index || -1; var i2 = b.index || -1; if ( i1 === i2 ) return 0; return ( i1 < i2 ) ? -1 : 1; }); var l = vix.length; for ( var ix=0; ix' ).val( vix[ix].name ).text( vix[ix].name ).appendTo( $el ); } if ( currExpr && 0 === $( "option[value=" + JSON.stringify( currExpr ) + "]", $el ).length ) { $( '' ).val( currExpr ).text( currExpr + " (undefined)" ) .prependTo( $el ); } $( '' ).val( "" ).text( '--choose--' ).prependTo( $el ); $el.val( coalesce( currExpr, "" ) ); return $el; } /** * Make a service/variable menu of all state defined for the device. Be * brief, using only the variable name in the menu, unless that name is * used by multiple services, in which case the last component of the * serviceId is added parenthetically to draw the distinction. */ function makeVariableMenu( device, service, variable ) { var el = $(''); var myid = api.getCpanelDeviceId(); if ( -1 === device ) device = myid; var devobj = api.getDeviceObject( device ); if ( devobj ) { var mm = {}, ms = []; var l = devobj.states ? devobj.states.length : 0; for ( var k=0; k' + sv.text + '' ); }); if ( 0 === r.length ) { el.append( '' ); } } if ( isEmpty( service ) || isEmpty( variable ) ) { menuSelectDefaultFirst( el, "" ); } else { var key = service + "/" + variable; menuSelectDefaultInsert( el, key ); } return el; } function makeServiceOpMenu( op ) { var el = $(''); var l = serviceOps.length; for ( var ix=0; ix').val(serviceOps[ix].op).text(serviceOps[ix].desc || serviceOps[ix].op) ); } if ( undefined !== op ) { el.val( op ); } return el; } function makeDateTimeOpMenu( op ) { var el = $(''); el.append( '' ); el.append( '' ); if ( undefined !== op ) { el.val( op ); } return el; } /* Make a menu of eligible groups in a ReactorSensor */ function makeRSGroupMenu( cond ) { var mm = $( '' ); mm.empty(); $( '' ).val( "" ).text("--choose--").appendTo( mm ); try { var dc; var myid = api.getCpanelDeviceId(); var myself = -1 === cond.device || cond.device === myid; if ( myself ) { /* Our own groups */ dc = getConfiguration( myid ); } else { dc = getConfiguration( cond.device ); } if ( dc ) { var appendgrp = function ( grp, sel, pg ) { /* Don't add ancestors in same RS */ if ( "nul" !== grp.operator && ! ( myself && isAncestor( grp.id, cond.id, myid ) ) ) { sel.append( $( '' ).val( grp.id ) .text( "root"===grp.id ? "Tripped/Untripped (root)" : ( grp.name || grp.id ) ) ); } /* Don't scan siblings or anything below. */ if ( myself && grp.id == pg.id ) return; var l = grp.conditions ? grp.conditions.length : 0; for ( var ix=0; ix 2037 ) ) { target.addClass( 'tberror' ); } else { var losOtros; if ( pdiv.hasClass('start') ) { losOtros = $('div.re-endfields input.year', $row); } else { losOtros = $('div.re-startfields input.year', $row); } if ( newval === "" && losOtros.val() !== "" ) { losOtros.val(""); } else if ( newval !== "" && losOtros.val() === "" ) { losOtros.val(newval); } } } var mon = $("div.re-startfields select.monthmenu", $row).val() || ""; if ( isEmpty( mon ) ) { /* No/any month. Disable years. */ $( '.datespec', $row ).val( "" ).prop( 'disabled', true ); /* Ending month must also be blank */ $( 'div.re-endfields select.monthmenu', $row ).val( "" ); } else { /* Month specified, year becomes optional, but either both years must be specified or neither for between/not. */ $( '.datespec', $row ).prop( 'disabled', false ); $( 'div.re-startfields select.daymenu:has(option[value=""]:selected)', $row ).addClass( 'tberror' ); if ( between ) { $( 'div.re-endfields select.daymenu:has(option[value=""]:selected)', $row ).addClass( 'tberror' ); var y1 = $( 'div.re-startfields input.year', $row ).val() || ""; var y2 = $( 'div.re-endfields input.year', $row ).val() || ""; if ( isEmpty( y1 ) !== isEmpty( y2 ) ) { $( '.datespec', $row ).addClass( 'tberror' ); } var m2 = $( 'div.re-endfields select.monthmenu', $row ).val() || ""; if ( isEmpty( m2 ) ) { /* Ending month may not be blank--flag both start/end */ $( 'select.monthmenu', $row ).addClass( 'tberror' ); } } } var dom = $( 'div.re-startfields select.daymenu', $row ).val() || ""; if ( isEmpty( dom ) ) { /* Start day is blank. So must be end day */ $( 'div.re-endfields select.daymenu', $row ).val( "" ); } else if ( between ) { /* Between with start day, end day must also be specified. */ $( 'div.re-endfields select.daymenu:has(option[value=""]:selected)', $row ).addClass( 'tberror' ); } /* Fetch and load */ res = []; res.push( isEmpty( mon ) ? "" : $("div.re-startfields input.year", $row).val() || "" ); res.push( mon ); res.push( $("div.re-startfields select.daymenu", $row).val() || "" ); res.push( $("div.re-startfields select.hourmenu", $row).val() || "0" ); res.push( $("div.re-startfields select.minmenu", $row).val() || "0" ); if ( ! between ) { Array.prototype.push.apply( res, ["","","","",""] ); $('div.re-endfields', $row).hide(); } else { $('div.re-endfields', $row).show(); res.push( isEmpty( mon ) ? "" : $("div.re-endfields input.year", $row).val() || "" ); res.push( isEmpty( mon ) ? "" : $("div.re-endfields select.monthmenu", $row).val() || "" ); res.push( $("div.re-endfields select.daymenu", $row).val() || "" ); res.push( $("div.re-endfields select.hourmenu", $row).val() || "0" ); res.push( $("div.re-endfields select.minmenu", $row).val() || "0" ); } cond.value = res.join(','); break; case 'sun': removeConditionProperties( cond, "operator,value,options" ); cond.operator = $('div.params select.opmenu', $row).val() || "after"; res = []; var whence = $('div.params select.re-sunstart', $row).val() || "sunrise"; var offset = getInteger( $('div.params input.re-startoffset', $row).val() || "0" ); if ( isNaN( offset ) ) { /* Validation error, flag and treat as 0 */ offset = 0; $('div.params input.re-startoffset', $row).addClass('tberror'); } res.push( whence + ( offset < 0 ? '' : '+' ) + String(offset) ); if ( cond.operator == "bet" || cond.operator == "nob" ) { $( 'div.re-endfields', $row ).show(); whence = $('select.re-sunend', $row).val() || "sunset"; offset = getInteger( $('input.re-endoffset', $row).val() || "0" ); if ( isNaN( offset ) ) { offset = 0; $('div.params input.re-endoffset', $row).addClass('tberror'); } res.push( whence + ( offset < 0 ? '' : '+' ) + String(offset) ); } else { $( 'div.re-endfields', $row ).hide(); res.push(""); } cond.value = res.join(','); break; case 'interval': removeConditionProperties( cond, "days,hours,mins,basetime,basedate,relto,relcond,options" ); var nmin = 0; var v = $('div.params .re-days', $row).val() || "0"; if ( v.match( varRefPattern ) ) { cond.days = v; nmin = 1440; } else { v = getOptionalInteger( v, 0 ); if ( isNaN(v) || v < 0 ) { $( 'div.params .re-days', $row ).addClass( 'tberror' ); } else { cond.days = v; nmin = nmin + 1440 * v; } } if ( typeof(cond.days) == "string" || ( typeof(cond.days) == "number" && 0 !== cond.days ) ) { $('div.params .re-hours,.re-mins', $row).prop('disabled', true).val("0"); cond.hours = 0; cond.mins = 0; } else { $('div.params .re-hours,.re-mins', $row).prop('disabled', false); v = $('div.params .re-hours', $row).val() || "0"; if ( v.match( varRefPattern ) ) { cond.hours = v; nmin = 60; } else { v = getOptionalInteger( v, 0 ); if ( isNaN(v) || v < 0 || v > 23 ) { $( 'div.params .re-hours', $row ).addClass( 'tberror' ); } else { cond.hours = v; nmin = nmin + 60 * v; } } v = $('div.params .re-mins', $row).val() || "0"; if ( v.match( varRefPattern ) ) { cond.mins = v; nmin = 1; } else { v = getOptionalInteger( v, 0 ); if ( isNaN(v) || v < 0 || v > 59 ) { $( 'div.params .re-mins', $row ).addClass( 'tberror' ); } else { cond.mins = v; nmin = nmin + v; } } if ( 0 !== nmin ) { $( '.re-days', $row ).prop( 'disabled', true ).val("0"); } else { $( '.re-days', $row ).prop( 'disabled', false ); } } if ( nmin <= 0 ) { $( 'div.params .re-days,.re-hours,.re-mins', $row ).addClass( 'tberror' ); } /* Interval relative to... */ v = $( 'div.params select.re-relto', $row ).val() || ""; if ( "condtrue" === v ) { cond.relto = v; cond.relcond = $( 'div.params select.re-relcond', $row).val() || ""; if ( "" === cond.relcond ) { $( 'div.params select.re-relcond', $row ).addClass( 'tberror' ); } delete cond.basetime; delete cond.basedate; } else { var ry = $( 'div.params input.re-relyear', $row ).val() || ""; if ( isEmpty( ry ) ) { delete cond.basedate; $( '.re-reldate', $row ).prop( 'disabled', true ); } else { $( '.re-reldate', $row ).prop( 'disabled', false ); cond.basedate = ry + "," + ( $( 'div.params select.re-relmon', $row ).val() || "1" ) + "," + ( $( 'div.params select.re-relday', $row ).val() || "1" ); } var rh = $( 'div.params select.re-relhour', $row ).val() || "00"; var rm = $( 'div.params select.re-relmin', $row ).val() || "00"; if ( rh == "00" && rm == "00" && isEmpty( cond.basedate ) ) { delete cond.basetime; } else { cond.basetime = rh + "," + rm; } delete cond.relcond; delete cond.relto; } break; case 'ishome': removeConditionProperties( cond, "operator,value,options" ); cond.operator = $("div.params select.geofencecond", $row).val() || "is"; res = []; if ( "at" === cond.operator || "notat" === cond.operator ) { res[0] = $( 'select.re-userid', $row ).val() || ""; res[1] = $( 'select.re-location', $row ).val() || ""; if ( isEmpty( res[0] ) ) { $( 'select.re-userid', $row ).addClass( 'tberror' ); } if ( isEmpty( res[1] ) ) { $( 'select.re-location', $row ).addClass( 'tberror' ); } } else { $("input.useropt:checked", $row).each( function( ix, control ) { res.push( control.value /* DOM element */ ); }); } cond.value = res.join( ',' ); break; case 'reload': /* No parameters */ removeConditionProperties( cond, "options" ); break; default: break; } /* If condition options are present, check them, too. */ var $ct = $row.hasClass( 'cond-group' ) ? $row.children( 'div.condopts' ) : $( 'div.condopts', $row ); if ( $ct.length > 0 ) { cond.options = cond.options || {}; /* Predecessor condition (sequencing) */ var $pred = $( 'select.re-predecessor', $ct ); if ( isEmpty( $pred.val() ) ) { $( 'input.re-predtime', $ct ).prop( 'disabled', true ).val( "" ); $( 'input.predmode', $ct ).prop( 'disabled', true ); if ( undefined !== cond.options.after ) { delete cond.options.after; delete cond.options.aftertime; delete cond.options.aftermode; configModified = true; } } else { $( 'input.re-predtime', $ct ).prop( 'disabled', false ); $( 'input.predmode', $ct ).prop( 'disabled', false ); var pt = parseInt( $('input.re-predtime', $ct).val() ); if ( isNaN( pt ) || pt < 0 ) { pt = 0; $('input.re-predtime', $ct).val(pt); } var predmode = $( 'input.predmode', $ct ).prop( 'checked' ) ? 0 : 1; if ( cond.options.after !== $pred.val() || cond.options.aftertime !== pt || ( cond.options.aftermode || 0 ) != predmode ) { cond.options.after = $pred.val(); cond.options.aftertime = pt; if ( predmode == 1 ) cond.options.aftermode = 1; else delete cond.options.aftermode; configModified = true; } } /* Repeats */ var $rc = $('input.re-repeatcount', $ct); if ( isEmpty( $rc.val() ) || $rc.prop('disabled') ) { $('input.re-duration', $ct).prop('disabled', false); $('select.re-durop', $ct).prop('disabled', false); $('input.re-repeatspan', $ct).val("").prop('disabled', true); if ( undefined !== cond.options.repeatcount ) { delete cond.options.repeatcount; delete cond.options.repeatwithin; configModified = true; } } else { val = getInteger( $rc.val() ); if ( isNaN( val ) || val < 2 ) { $rc.addClass( 'tberror' ); } else if ( val > 1 ) { $rc.removeClass( 'tberror' ); if ( val != cond.options.repeatcount ) { cond.options.repeatcount = val; delete cond.options.duration; delete cond.options.duration_op; configModified = true; } $('input.re-duration', $ct).val("").prop('disabled', true); $('select.re-durop', $ct).val("ge").prop('disabled', true); $('input.re-repeatspan', $ct).prop('disabled', false); if ( $('input.re-repeatspan', $ct).val() === "" ) { $('input.re-repeatspan', $ct).val( "60" ); cond.options.repeatwithin = 60; configModified = true; } } } var $rs = $('input.re-repeatspan', $ct); if ( ! $rs.prop('disabled') ) { var rspan = getInteger( $rs.val() ); if ( isNaN( rspan ) || rspan < 1 ) { $rs.addClass( 'tberror' ); } else { $rs.removeClass( 'tberror' ); if ( rspan !== ( cond.options.repeatwithin || 0 ) ) { cond.options.repeatwithin = rspan; configModified = true; } } } /* Duration (sustained for) */ var $dd = $('input.re-duration', $ct); if ( isEmpty( $dd.val() ) || $dd.prop('disabled') ) { $('input.re-repeatcount', $ct).prop('disabled', false); // $('input.re-repeatspan', $ct).prop('disabled', false); if ( undefined !== cond.options.duration ) { delete cond.options.duration; delete cond.options.duration_op; configModified = true; } } else { var dur = getInteger( $dd.val() ); if ( isNaN( dur ) || dur < 0 ) { $dd.addClass('tberror'); } else { $dd.removeClass('tberror'); $('input.re-repeatcount', $ct).val("").prop('disabled', true); // $('input.re-repeatspan', $ct).val("").prop('disabled', true); delete cond.options.repeatwithin; delete cond.options.repeatcount; if ( ( cond.options.duration || 0 ) !== dur ) { /* Changed */ if ( dur === 0 ) { delete cond.options.duration; delete cond.options.duration_op; $('input.re-repeatcount', $ct).prop('disabled', false); // $('input.re-repeatspan', $ct).prop('disabled', false); } else { cond.options.duration = dur; cond.options.duration_op = $('select.re-durop', $ct).val() || "ge"; } configModified = true; } } } var mode = $( 'input.opt-output:checked', $ct ).val() || ""; if ( "L" === mode ) { /* Latching */ $( '.followopts,.pulseopts', $ct ).prop( 'disabled', true ); $( '.latchopts', $ct ).prop( 'disabled', false ); configModified = configModified || ( undefined !== cond.options.holdtime ); delete cond.options.holdtime; configModified = configModified || ( undefined !== cond.options.pulsetime ); delete cond.options.pulsetime; delete cond.options.pulsebreak; delete cond.options.pulsecount; if ( undefined === cond.options.latch ) { cond.options.latch = 1; configModified = true; } } else if ( "P" === mode ) { /* Pulse output */ $( '.followopts,.latchopts', $ct ).prop( 'disabled', true ); $( '.pulseopts', $ct ).prop( 'disabled', false ); configModified = configModified || ( undefined !== cond.options.holdtime ); delete cond.options.holdtime; $( 'input.re-pulsetime', $ct ).prop( 'disabled', false ); configModified = configModified || ( undefined !== cond.options.latch ); delete cond.options.latch; var $f = $( 'input.re-pulsetime', $ct ); var pulsetime = $f.val() || ""; if ( isEmpty( pulsetime ) ) { pulsetime = 15; /* force a default */ $f.val( pulsetime ); configModified = configModified || pulsetime !== cond.options.pulsetime; cond.options.pulsetime = pulsetime; } else { pulsetime = getInteger( pulsetime ); if ( isNaN( pulsetime ) || pulsetime <= 0 ) { $f.addClass( 'tberror' ); } else if ( pulsetime !== cond.options.pulsetime ) { cond.options.pulsetime = pulsetime; configModified = true; } } var repeats = "repeat" === $( 'select.re-pulsemode', $ct ).val(); $( "div.re-pulsebreakopts", $ct ).toggle( repeats ); if ( repeats ) { $f = $( 'input.re-pulsebreak', $ct ); pulsetime = parseInt( $f.val() || "" ); if ( isNaN( pulsetime ) || pulsetime <= 0 ) { $f.addClass( 'tberror' ); } else { if ( pulsetime !== cond.options.pulsebreak ) { cond.options.pulsebreak = pulsetime; configModified = true; } } $f = $( 'input.re-pulsecount', $ct ); var lim = $f.val() || ""; if ( isEmpty( lim ) ) { if ( 0 !== cond.options.pulsecount ) { delete cond.options.pulsecount; configModified = true; } } else { lim = parseInt( lim ); if ( isNaN( lim ) || lim < 0 ) { $f.addClass( 'tberror' ); } else if ( 0 === lim && cond.options.pulsecount ) { delete cond.options.pulsecount; configModified = true; } else if ( cond.options.pulsecount !== lim ) { cond.options.pulsecount = lim; configModified = true; } } } else { if ( undefined !== cond.options.pulsebreak ) { configModified = true; } delete cond.options.pulsebreak; delete cond.options.pulsecount; } } else { /* Follow mode (default) */ $( '.pulseopts,.latchopts', $ct ).prop( 'disabled', true ); $( '.followopts', $ct ).prop( 'disabled', false ); configModified = configModified || ( undefined !== cond.options.pulsetime ); delete cond.options.pulsetime; delete cond.options.pulsebreak; delete cond.options.pulsecount; configModified = configModified || ( undefined !== cond.options.latch ); delete cond.options.latch; /* Hold time (delay reset) */ $dd = $( 'input.re-holdtime', $ct ); if ( isEmpty( $dd.val() ) ) { /* Empty and 0 are equivalent */ configModified = configModified || ( undefined !== cond.options.holdtime ); delete cond.options.holdtime; } else { var holdtime = getInteger( $dd.val() ); if ( isNaN( holdtime ) || holdtime < 0 ) { delete cond.options.holdtime; $dd.addClass( 'tberror' ); } else if ( ( cond.options.holdtime || 0 ) !== holdtime ) { if ( holdtime > 0 ) { cond.options.holdtime = holdtime; } else { delete cond.options.holdtime; } configModified = true; } } } } /* Options open or not, make sure options expander is highlighted */ var optButton = $( $row.hasClass( 'cond-group' ) ? '.cond-group-header > div > button.re-condmore:first' : '.cond-actions > button.re-condmore', $row ); if ( hasAnyProperty( cond.options ) ) { optButton.addClass( 'attn' ); } else { optButton.removeClass( 'attn' ); delete cond.options; } $row.has('.tberror').addClass('tberror'); updateSaveControls(); } /** * Handler for row change (generic change to some value we don't otherwise * need additional processing to respond to) */ function handleConditionRowChange( ev ) { var el = $( ev.currentTarget ); var row = el.closest('div.cond-container'); console.log('handleConditionRowChange ' + String(row.attr('id'))); row.addClass( 'tbmodified' ); configModified = true; updateConditionRow( row, el ); } /** * Update current value display for service condition */ function updateCurrentServiceValue( row ) { console.assert( row.hasClass("cond-cond") ); var device = parseInt( $("select.devicemenu", row).val() ); var service = $("select.varmenu", row).val() || ""; var variable = service.replace( /^[^\/]+\//, "" ); service = service.replace( /\/.*$/, "" ); var blk = $( 'div.currval', row ); if ( ! ( isNaN(device) || isEmpty( service ) || isEmpty( variable ) ) ) { var val = api.getDeviceState( device, service, variable ); if ( undefined === val || false === val ) { blk.text( 'Current value: (not set)' ).attr( 'title', "This variable is not present in the device state." ); } else { var abbrev = val.length > 64 ? val.substring(0,61) + '...' : val; blk.text( 'Current value: ' + abbrev ).attr( 'title', val.length==0 ? "The string is blank/empty." : val ); } } else { blk.empty().attr( 'title', "" ); } } /** * Handler for variable change. Change the displayed current value. */ function handleConditionVarChange( ev ) { var $el = $( ev.currentTarget ); var $row = $el.closest('div.cond-container'); updateCurrentServiceValue( $row ); /* Same closing as handleConditionRowChange() */ configModified = true; updateConditionRow( $row, $el ); } /* Set up fields for condition based on current operator */ function setUpConditionOpFields( $row, cond ) { var vv = (cond.value || "").split(/,/); if ( "housemode" === cond.type ) { if ( "change" == ( cond.operator || "is" ) ) { $( 'div.re-modechecks', $row ).hide(); $( 'div.re-modeselects', $row ).show(); menuSelectDefaultInsert( $( 'select.re-frommode', $row ), vv.length > 0 ? vv[0] : "" ); menuSelectDefaultInsert( $( 'select.re-tomode', $row ), vv.length > 1 ? vv[1] : "" ); } else { $( 'div.re-modechecks', $row ).show(); $( 'div.re-modeselects', $row ).hide(); vv.forEach( function( ov ) { $('input#' + idSelector( cond.id + '-mode-' + ov ), $row).prop('checked', true); }); } } else if ( "service" === cond.type || "var" === cond.type ) { var op = arrayFindValue( serviceOps, function( v ) { return v.op === cond.operator; } ) || serviceOps[0]; var $inp = $( 'input#' + idSelector( cond.id + '-value' ), $row ); if ( op.args > 1 ) { if ( $inp.length > 0 ) { /* Single input field; change this one for double */ $inp.attr( 'id', cond.id + '-val1' ).show(); } else { /* Already there */ $inp = $( 'input#' + idSelector( cond.id + '-val1' ), $row ); } /* Work on second field */ var $in2 = $( 'input#' + idSelector( cond.id + '-val2' ), $row ); if ( 0 === $in2.length ) { $in2 = $inp.clone().attr('id', cond.id + '-val2') .off( 'change.reactor' ).on( 'change.reactor', handleConditionRowChange ); $in2.insertAfter( $inp ); } if ( op.optional ) { $inp.attr( 'placeholder', 'blank=any value' ); $in2.attr( 'placeholder', 'blank=any value' ); } else { $inp.attr( 'placeholder', "" ); $in2.attr( 'placeholder', "" ); } /* Labels */ $( 'label.re-secondaryinput', $row ).remove(); var fmt = op.format || "%1,%2"; var lbl = fmt.match( /^([^%]*)%\d+([^%]*)%\d+(.*)$/ ); if ( null !== lbl ) { if ( !isEmpty( lbl[1] ) ) { $( '' ) .attr( 'for', cond.id + "-val1" ) .text( lbl[1] ) .insertBefore( $inp ); } if ( !isEmpty( lbl[2] ) ) { $( '' ) .attr( 'for', cond.id + "-val2" ) .text( lbl[2] ) .insertBefore( $in2 ); } if ( !isEmpty( lbl[3] ) ) { $( '' ) .text( lbl[3] ) .insertAfter( $in2 ); } } /* Restore values */ $inp.val( vv.length > 0 ? String(vv[0]) : "" ); $( 'input#' + idSelector( cond.id + '-val2' ), $row ).val( vv.length > 1 ? String(vv[1]) : "" ); } else { if ( $inp.length == 0 ) { /* Convert double fields back to single */ $inp = $( 'input#' + idSelector( cond.id + '-val1' ), $row ) .attr( 'id', cond.id + '-value' ) .attr( 'placeholder', '' ); $( 'input#' + idSelector( cond.id + '-val2' ) + ',label.re-secondaryinput', $row ).remove(); } $inp.val( vv.length > 0 ? String(vv[0]) : "" ); if ( 0 === op.args ) { $inp.val("").hide(); } else { $inp.show(); } } var $opt = $( 'div.re-nocaseopt', $row ); if ( 0 === ( op.numeric || 0 ) && false !== op.nocase ) { $opt.show(); $( 'input.nocase', $opt ).prop( 'checked', coalesce( cond.nocase, 1 ) !== 0 ); } else { $opt.hide(); } } else if ( "grpstate" === cond.type ) { /* nada */ } else { console.log( "Invalid row type in handleConditionOperatorChange(): " + String( cond.type ) ); return; } } /** * Handler for operator change */ function handleConditionOperatorChange( ev ) { var $el = $( ev.currentTarget ); var val = $el.val(); var $row = $el.closest('div.cond-container'); var cond = getConditionIndex()[ $row.attr( 'id' ) ]; cond.operator = val; setUpConditionOpFields( $row, cond ); configModified = true; updateConditionRow( $row, $el ); } /** * Handler for device change */ function handleDeviceChange( ev ) { var $el = $( ev.currentTarget ); var newDev = $el.val(); var $row = $el.closest( 'div.cond-container' ); var condId = $row.attr( 'id' ); var cond = getConditionIndex()[condId]; cond.device = parseInt( newDev ); if ( -1 === cond.device ) { cond.devicename = "(self)"; } else { var dobj = api.getDeviceObject( cond.device ); cond.devicename = ( dobj || {}).name; } delete cond.deviceName; /* remove old form */ configModified = true; /* Make a new service/variable menu and replace it on the row. */ var newMenu = makeVariableMenu( cond.device, cond.service, cond.variable ); $("select.varmenu", $row).replaceWith( newMenu ); $("select.varmenu", $row).off( 'change.reactor' ).on( 'change.reactor', handleConditionVarChange ); newMenu = makeEventMenu( cond, $row ); $( 'div.eventlist', $row ).replaceWith( newMenu ); updateCurrentServiceValue( $row ); updateConditionRow( $row ); /* pass it on */ } function handleExpandOptionsClick( ev ) { var $el = $( ev.currentTarget ); var $row = $el.closest( 'div.cond-container' ); var isGroup = $row.hasClass( 'cond-group' ); var cond = getConditionIndex()[ $row.attr( "id" ) ]; /* If the options container already exists, just show it. */ var $container = $( isGroup ? 'div.condopts' : 'div.cond-body > div.condopts', $row ); if ( $container.length > 0 ) { /* Container exists and is open, close it, remove it. */ $container.slideUp({ complete: function() { $container.remove(); } }); $( 'i', $el ).text( 'expand_more' ); $el.attr( 'title', msgOptionsShow ); if ( $row.hasClass( 'tbautohidden' ) ) { $( '.cond-group-title button.re-expand', $row ).click(); $row.removeClass( 'tbautohidden' ); } return; } /* Doesn't exist. Create the options container and add options */ $( 'i', $el ).text( 'expand_less' ); $el.attr( 'title', msgOptionsHide ); $container = $( '
' ).hide(); var displayed = condOptions[ cond.type || "group" ] || {}; var condOpts = cond.options || {}; /* Options now fall into two general groups: output control, and restrictions. */ /* Output Control */ var out = $( '
', { "id": "outputopt", "class": "tboptgroup" } ).appendTo( $container ); $( '
Output Control
' ).append( getWiki( 'Condition-Options' ) ).appendTo( out ); var fs = $( '
' ).appendTo( out ); var rid = "output" + getUID(); getRadio( rid, 1, "", "Follow (default) - output remains true while condition matches", "opt-output" ) .appendTo( fs ); if ( false !== displayed.hold ) { fs.append( '; ' ); $( '
' ) .appendTo( fs ); } /* Pulse group is not displayed for update and change operators; always display if configured, though, do any legacy configs prior to this restriction being added are still editable. */ if ( ( false !== displayed.pulse && !(cond.operator || "=").match( /^(update|change)/ ) ) || condOpts.pulsetime ) { fs = $( '
' ).appendTo( out ); getRadio( rid, 2, "P", "Pulse - output goes true for", "opt-output" ) .appendTo( fs ); $( ' seconds' ) .appendTo( fs ); $( '
' ) .appendTo( fs ); } if ( false !== displayed.latch ) { fs = $( '
' ).appendTo( out ); getRadio( rid, 3, "L", "Latch - output is held true until external reset", "opt-output" ) .appendTo( fs ); } /* Restore/configure */ if ( ( condOpts.pulsetime || 0 ) > 0 ) { $( '.pulseopts', out ).prop( 'disabled', false ); $( 'input#' + idSelector(rid + '2'), out ).prop( 'checked', true ); $( 'input.re-pulsetime', out ).val( condOpts.pulsetime || 15 ); $( 'input.re-pulsebreak', out ).val( condOpts.pulsebreak || "" ); $( 'input.re-pulsecount', out ).val( condOpts.pulsecount || "" ); var pbo = (condOpts.pulsebreak || 0) > 0; $( 'select.re-pulsemode', out ).val( pbo ? "repeat" : "" ); $( 'div.re-pulsebreakopts', out ).toggle( pbo ); $( '.followopts,.latchopts', out ).prop( 'disabled', true ); } else if ( 0 !== ( condOpts.latch || 0 ) ) { $( '.latchopts', out ).prop( 'disabled', false ); $( 'input#' + idSelector(rid + '3'), out ).prop( 'checked', true ); $( '.followopts,.pulseopts', out ).prop( 'disabled', true ); $( 'div.re-pulsebreakopts', out ).toggle( false ); } else { $( '.followopts', out ).prop( 'disabled', false ); $( 'input#' + idSelector(rid + '1'), out ).prop( 'checked', true ); $( '.latchopts,.pulseopts', out ).prop( 'disabled', true ); $( 'div.re-pulsebreakopts', out ).toggle( false ); $( 'input.re-holdtime', out ).val( 0 !== (condOpts.holdtime || 0) ? condOpts.holdtime : "" ); } /* Restrictions */ if ( displayed.sequence || displayed.duration || displayed.repeat ) { var rst = $( '
', { "id": "restrictopt", "class": "tboptgroup" } ).appendTo( $container ); $( '
Restrictions
' ).append( getWiki( 'Condition-Options' ) ).appendTo( rst ); /* Sequence (predecessor condition) */ if ( displayed.sequence ) { fs = $( '
' ).appendTo( rst ); var $preds = $(''); /* Add groups that are not ancestor of condition */ DOtraverse( (getConditionIndex()).root, function( node ) { $preds.append( $( '' ).val( node.id ).text( makeConditionDescription( node ) ) ); }, false, function( node ) { /* If node is not ancestor (line to root) or descendent of cond, allow as predecessor */ return "comment" !== node.type && cond.id !== node.id && !isAncestor( node.id, cond.id ) && !isDescendent( node.id, cond.id ); }); $( '' ) .append( $preds ) .appendTo( fs ); fs.append(' '); fs.append( getCheckbox( getUID("check"), "0", "Predecessor must still be true for this condition to go true", "predmode" ) ); $('select.re-predecessor', fs).val( condOpts.after || "" ); $('input.re-predtime', fs).val( condOpts.aftertime || 0 ) .prop( 'disabled', "" === ( condOpts.after || "" ) ); $('input.predmode', fs) .prop( 'checked', 0 === ( condOpts.aftermode || 0 ) ) .prop( 'disabled', "" === ( condOpts.after || "" ) ); } /* Duration */ if ( displayed.duration ) { fs = $( '
' ).appendTo( rst ); fs.append(''); } /* Repeat */ if ( displayed.repeat ) { fs = $( '
' ).appendTo( rst ); fs.append(''); } if ( ( condOpts.duration || 0 ) > 0 ) { $('input.re-repeatcount,input.re-repeatspan', rst).prop('disabled', true); $('input.re-duration', rst).val( condOpts.duration ); $('select.re-durop', rst).val( condOpts.duration_op || "ge" ); } else { var rc = condOpts.repeatcount || ""; $('input.re-duration', rst).prop('disabled', rc != ""); $('select.re-durop', rst).prop('disabled', rc != ""); $('input.re-repeatcount', rst).val( rc ); $('input.re-repeatspan', rst).prop('disabled', rc=="").val( rc == "" ? "" : ( condOpts.repeatwithin || "60" ) ); } } /* Handler for all fields */ $( 'input,select', $container ).on( 'change.reactor', handleConditionRowChange ); /* Add the options container (specific immediate child of this row selection) */ if ( isGroup ) { $row.append( $container ); if ( 1 === $( '.cond-group-title button.re-collapse', $row ).length ) { $( '.cond-group-title button.re-collapse', $row ).click(); $row.addClass('tbautohidden'); } } else { $row.children( 'div.cond-body' ).append( $container ); } $container.slideDown(); } /** * Update location selector to show correct locations for selected user. */ function updateGeofenceLocations( row, loc ) { var user = $( 'select.re-userid', row ).val() || ""; var mm = $( 'select.re-location', row ); mm.empty(); if ( "" !== user ) { var ud = api.getUserData(); var l = ud.usergeofences ? ud.usergeofences.length : 0; for ( var k=0; k' ).val( "" ).text( '--choose location--' ) ); $.each( ud.usergeofences[k].geotags || [], function( ix, v ) { mm.append( $( '' ).val( v.id ).text( v.name ) ); }); var el = $( 'option[value="' + (loc || "") + '"]' ); if ( el.length == 0 ) { mm.append( $( '' ).val( loc ) .text( "Deleted location " + String(loc) ) ); } mm.val( loc || "" ); break; } } } } /** * Handle user selector changed event. */ function handleGeofenceUserChange( ev ) { var row = $( ev.currentTarget ).closest( 'div.cond-container' ); updateGeofenceLocations( row, "" ); handleConditionRowChange( ev ); } /** * Handle geofence operator change event. */ function handleGeofenceOperatorChange( ev ) { var el = $( ev.currentTarget ); var row = el.closest( 'div.cond-container' ); var val = el.val() || "is"; if ( "at" === val || "notat" === val ) { $( 'div.re-geolong', row ).show(); $( 'div.re-geoquick', row ).hide(); } else { $( 'div.re-geolong', row ).hide(); $( 'div.re-geoquick', row ).show(); } handleConditionRowChange( ev ); } function firstKey( t ) { for ( var d in t ) { if ( t.hasOwnProperty( d ) ) return d; } return undefined; } /** * Make event menu from static JSON eventlist for device */ function makeEventMenu( cond, $row ) { var el = $( '' ); var btnid = getUID('btn'); el.append( '' ); $( 'button.device-triggers', el ).attr( 'id', btnid ); var mm = $( '' ).attr( 'aria-labelledby', btnid ); el.append( mm ); var myid = api.getCpanelDeviceId(); var myself = -1 === cond.device || cond.device === myid; var events; if ( ! myself ) { if ( isALTUI ) { /* AltUI doesn't implement getDeviceTemplate() as of 2019-06-09 */ var dobj = api.getDeviceObject( myself ? myid : cond.device ); var eobj = dobj ? api.getEventDefinition( dobj.device_type ) || {} : {}; /* AltUI returns object; reduce to array */ events = []; for ( var ie=0; undefined !== eobj[String(ie)] ; ie++ ) { events.push( eobj[String(ie)] ); } } else { var dtmp = api.getDeviceTemplate( myself ? myid : cond.device ); events = dtmp ? dtmp.eventList2 : false; } } if ( events && events.length > 0 ) { var wrapAction = function( eventinfo, cond, $row ) { return function( ev ) { var el = $( ev.target ); cond.service = el.data( 'service' ) || "?"; cond.variable = el.data( 'variable' ) || "?"; cond.operator = el.data( 'operator' ) || "="; cond.value = el.data( 'value' ) || ""; delete cond.nocase; var sk = cond.service + "/" + cond.variable; menuSelectDefaultInsert( $( 'select.varmenu', $row ), sk ); $( 'select.opmenu', $row ).val( cond.operator ); setUpConditionOpFields( $row, cond ); $( 'input#' + idSelector( cond.id + '-value' ), $row ).val( cond.value ); configModified = true; updateCurrentServiceValue( $row ); updateConditionRow( $row, $( ev ) ); $( 'select.varmenu', $row ).focus(); /* Hotfix 20103-01: Older UI/firmware (<=1040 at least) seems to need this to prevent the button click from returning to the UI to the dashboard, which is truly bizrre. */ ev.preventDefault(); }; }; var reptext = function( s ) { return ( s || "?" ).replace( /_DEVICE_NAME_/g, "device" ).replace( /_ARGUMENT_VALUE_/g, "value" ); }; var lx = events.length; for ( var ix=0; ix' ); item.attr( 'id', cx.id ); k = firstKey( cx.serviceStateTable ); item.data('service', cx.serviceId); item.data('variable', k); item.data('operator', (cx.serviceStateTable[k] || {}).comparisson || "="); item.data('value', String( cx.serviceStateTable[k].value ) ); txt = reptext( (cx.label || {}).text || String(cx.id) ); item.html( txt ); mm.append( item ); item.on( 'click.reactor', wrapAction( cx, cond, $row ) ); } else { /* argumentList */ var ly = cx.argumentList ? cx.argumentList.length : 0; for ( var iy=0; iy' ); item.attr( 'id', cx.id ); item.data('service', cx.serviceId); item.data( 'variable', arg.name ); item.data( 'operator', arg.comparisson || "=" ); k = firstKey( av ); item.data( 'value', String( av[k] || "" ) ); item.attr( 'id', arg.id ); item.html( reptext( av.HumanFriendlyText.text || "(invalid device_json description)" ) ); mm.append( item ); item.on( 'click.reactor', wrapAction( cx, cond, $row ) ); } } else { item = $( '' ); item.data( 'id', cx.id ); item.data('service', cx.serviceId); item.data( 'variable', arg.name ); item.data( 'operator', arg.comparisson || "=" ); item.data( 'value', ( undefined === arg.defaultValue || arg.optional ) ? "" : String( arg.defaultValue ) ); item.attr( 'id', arg.id ); item.html( reptext( arg.HumanFriendlyText.text || "(invalid device_json description)" ) ); mm.append( item ); item.on( 'click.reactor', wrapAction( cx, cond, $row ) ); } } } } } if ( $( 'a', mm ).length > 0 ) { mm.append( $( '' ) ); mm.append( $( '' ) .text( "In addition to the above device-defined events, you can select any state variable defined on the device and test its value." ) ); } else { mm.append( $( '' ).text( "This device does not define any events." ) ); } return el; } /** * Set condition fields and data for type. This also replaces existing * data from the passed condition. The condition must have at least * id and type keys set (so new conditions may be safely be otherwise * empty). */ function setConditionForType( cond, row ) { var op, k, v, mm, fs, el, dobj; if ( undefined === row ) { row = $( 'div.cond-container#' + idSelector( cond.id ) ); } var container = $('div.params', row).empty().addClass("form-inline"); container.closest( 'div.cond-body' ).addClass("form-inline"); row.children( 'button.re-condmore' ).prop( 'disabled', "comment" === cond.type ); switch (cond.type) { case "": break; case 'comment': container.removeClass("form-inline"); container.closest("div.cond-body").removeClass("form-inline"); container.append(''); $('input', container).on( 'change.reactor', handleConditionRowChange ).val( cond.comment || "" ); if ( "cond0" === cond.id && ( cond.comment || "").match( /^Welcome to your new Reactor/i ) ) { $( '
New to Reactor? Check out the tutorial videos. ' + 'There\'s also the Reactor Documentation and Community Forum Category.
' ).appendTo( container ); } break; case 'service': case 'var': if ( "var" === cond.type ) { var $mm = makeExprMenu( cond['var'] ); container.append( $mm ); $mm.on( "change.reactor", handleConditionRowChange ); } else { container.append( makeDeviceMenu( cond.device, cond.devicename || "unknown device" ) ); /* Fix-up: makeDeviceMenu will display current userdata name for device, but if that's changed from what we've stored, we need to update our store. */ v = cond.devicename; if ( -1 === cond.device ) { v = "(self)"; } else { dobj = api.getDeviceObject( cond.device ); v = (dobj || {}).name; /* may be undefined, that's OK */ } if ( v && cond.devicename !== v ) { cond.devicename = v; delete cond.deviceName; /* remove old form */ configModified = true; } fs = $('
') .appendTo( container ); try { fs.append( makeEventMenu( cond, row ) ); } catch( e ) { console.log("Error while attempting to handle device JSON: " + String(e)); } fs.append( makeVariableMenu( cond.device, cond.service, cond.variable ) ); $("select.varmenu", container).on( 'change.reactor', handleConditionVarChange ); $("select.devicemenu", container).on( 'change.reactor', handleDeviceChange ); } if ( isEmpty( cond.operator ) ) { cond.operator = "="; configModified = true; } container.append( makeServiceOpMenu( cond.operator ) ); container.append(''); v = $( '
' ).appendTo( container ); getCheckbox( cond.id + "-nocase", "1", "Ignore case", "nocase" ) .appendTo( v ); container.append('
'); setUpConditionOpFields( container, cond ); $("input.operand", container).on( 'change.reactor', handleConditionRowChange ); $('input.nocase', container).on( 'change.reactor', handleConditionRowChange ); $("select.opmenu", container).on( 'change.reactor', handleConditionOperatorChange ); if ( "var" === cond.type ) { /* Remove "updates" op for var condition */ $( "select.opmenu option[value='update']", container ).remove(); $( "div.currval", container ).text(""); } else { $( "select.opmenu option[value='isnull']", container ).remove(); updateCurrentServiceValue( row ); } break; case 'grpstate': /* Default device to current RS */ cond.device = coalesce( cond.device, -1 ); /* Make a device menu that shows ReactorSensors only. */ container.append( makeDeviceMenu( cond.device, cond.devicename || "unknown device", function( dev ) { return deviceType === dev.device_type; })); /* Fix-up: makeDeviceMenu will display current userdata name for device, but if that's changed from what we've stored, we need to update our store. */ v = cond.devicename; if ( -1 === cond.device ) { v = "(self)"; } else { dobj = api.getDeviceObject( cond.device ); v = (dobj || {}).name; /* may be undefined, that's OK */ } if ( cond.devicename !== v ) { cond.devicename = v; delete cond.deviceName; /* remove old form */ configModified = true; } /* Create group menu for selected device (if any) */ container.append( makeRSGroupMenu( cond ) ); /* Make operator menu, short: only boolean and change */ mm = $( '' ); mm.append( $( '' ).val( "istrue" ).text( "is TRUE" ) ); mm.append( $( '' ).val( "isfalse" ).text( "is FALSE" ) ); mm.append( $( '' ).val( "change" ).text( "changes" ) ); container.append( mm ); menuSelectDefaultFirst( mm, cond.operator ); container.append('
'); setUpConditionOpFields( container, cond ); $("select.opmenu", container).on( 'change.reactor', handleConditionRowChange ); $("select.re-grpmenu", container).on( 'change.reactor', handleConditionRowChange ); $("select.devicemenu", container).on( 'change.reactor', function( ev ) { var $el = $( ev.currentTarget ); var newDev = $el.val(); var $row = $el.closest( 'div.cond-container' ); var condId = $row.attr( 'id' ); var cond = getConditionIndex()[condId]; cond.device = parseInt( newDev ); if ( -1 === cond.device ) { cond.devicename = "(self)"; } else { var dobj = api.getDeviceObject( cond.device ); cond.devicename = (dobj || {}).name; } delete cond.deviceName; /* remove old form */ delete cond.groupname; delete cond.groupid; configModified = true; /* Make a new service/variable menu and replace it on the row. */ var newMenu = makeRSGroupMenu( cond ); $("select.re-grpmenu", $row).empty().append( newMenu.children() ); updateConditionRow( $row ); /* pass it on */ }); updateCurrentServiceValue( row ); break; case 'housemode': if ( isEmpty( cond.operator ) ) { cond.operator = "is"; } mm = $(''); mm.append( '' ); mm.append( '' ); menuSelectDefaultFirst( mm, cond.operator ); mm.on( 'change.reactor', handleConditionOperatorChange ); container.append( mm ); container.append( " " ); // Checkboxes in their own div var d = $( '
' ); for ( k=1; k<=4; k++ ) { getCheckbox( cond.id + '-mode-' + k, k, houseModeName[k] || k, "hmode" ) .appendTo( d ); } container.append( d ); $( "input.hmode", container ).on( 'change.reactor', handleConditionRowChange ); // Menus in a separate div d = $( '
' ); mm = $( '' ); mm.append( '' ); for ( k=1; k<=4; k++ ) { mm.append( $( '' ).val(k).text( houseModeName[k] ) ); } d.append( mm.clone().addClass( 're-frommode' ) ); d.append( " to " ); d.append( mm.addClass( 're-tomode' ) ); container.append( d ); $( 'select.re-frommode,select.re-tomode', container).on( 'change.reactor', handleConditionRowChange ); // Restore values and set up correct display. setUpConditionOpFields( container, cond ); break; case 'weekday': container.append( ''); fs = $( '
' ); getCheckbox( cond.id + '-wd-1', '1', 'Sun', 'wdopt' ).appendTo( fs ); getCheckbox( cond.id + '-wd-2', '2', 'Mon', 'wdopt' ).appendTo( fs ); getCheckbox( cond.id + '-wd-3', '3', 'Tue', 'wdopt' ).appendTo( fs ); getCheckbox( cond.id + '-wd-4', '4', 'Wed', 'wdopt' ).appendTo( fs ); getCheckbox( cond.id + '-wd-5', '5', 'Thu', 'wdopt' ).appendTo( fs ); getCheckbox( cond.id + '-wd-6', '6', 'Fri', 'wdopt' ).appendTo( fs ); getCheckbox( cond.id + '-wd-7', '7', 'Sat', 'wdopt' ).appendTo( fs ); fs.appendTo( container ); menuSelectDefaultFirst( $( 'select.wdcond', container ), cond.operator ); (cond.value || "").split(',').forEach( function( val ) { $('input.wdopt[value="' + val + '"]', container).prop('checked', true); }); $("input", container).on( 'change.reactor', handleConditionRowChange ); $("select.wdcond", container).on( 'change.reactor', handleConditionRowChange ); break; case 'sun': container.append( makeDateTimeOpMenu( cond.operator ) ); $("select.opmenu", container).append(''); $("select.opmenu", container).append(''); container.append('
' + ''+ ' offset  minutes' + '
' ); container.append('
 and ' + ' '+ ' offset  minutes' + '
' ); mm = $('' ); $('select.re-sunend', container).replaceWith( mm.clone().addClass( 're-sunend' ) ); $('select.re-sunstart', container).replaceWith( mm.addClass( 're-sunstart' ) ); /* Restore. Condition first... */ op = menuSelectDefaultFirst( $("select.opmenu", container), cond.operator ); $("select.opmenu", container).on( 'change.reactor', handleConditionRowChange ); if ( "bet" === op || "nob" === op ) { $("div.re-endfields", container).show(); } else { $("div.re-endfields", container).hide(); } /* Start */ var vals = ( cond.value || "sunrise+0,sunset+0" ).split(/,/); k = vals[0].match( /^([^+-]+)(.*)/ ); if ( k === null || k.length !== 3 ) { k = [ "", "sunrise", "0" ]; configModified = true; } $("select.re-sunstart", container).on( 'change.reactor', handleConditionRowChange ).val( k[1] ); $("input.re-startoffset", container).on( 'change.reactor', handleConditionRowChange ).val( k[2] ); /* End */ k = ( vals[1] || "sunset+0" ).match( /^([^+-]+)(.*)/ ); if ( k === null || k.length !== 3 ) { k = [ "", "sunset", "0" ]; configModified = true; } $("select.re-sunend", container).on( 'change.reactor', handleConditionRowChange ).val( k[1] ); $("input.re-endoffset", container).on( 'change.reactor', handleConditionRowChange ).val( k[2] ); break; case 'trange': container.append( makeDateTimeOpMenu( cond.operator ) ); $("select.opmenu", container).append(''); $("select.opmenu", container).append(''); var months = $(''); for ( k=1; k<=12; k++ ) { months.append(''); } var days = $(''); for ( k=1; k<=31; k++ ) { days.append(''); } var hours = $(''); for ( k=0; k<24; k++ ) { var hh = k % 12; if ( hh === 0 ) { hh = 12; } hours.append(''); } var mins = $(''); for ( var mn=0; mn<60; mn+=5 ) { mins.append(''); } container.append('
').append('
 and
'); $("div.re-startfields", container).append( months.clone() ) .append( days.clone() ) .append('') .append( hours.clone() ) .append( mins.clone() ); $("div.re-endfields", container).append( months ) .append( days ) .append('') .append( hours ) .append( mins ); /* Default all menus to first option */ $("select", container).each( function( ix, obj ) { $(obj).val( $("option:first", obj ).val() ); }); /* Restore values. */ op = menuSelectDefaultFirst( $( "select.opmenu", container ), cond.operator ); if ( "bet" === op || "nob" === op ) { $("div.re-endfields", container).show(); } else { $("div.re-endfields", container).hide(); } var vlist = (cond.value || "").split(','); var flist = [ 'div.re-startfields input.year', 'div.re-startfields select.monthmenu','div.re-startfields select.daymenu', 'div.re-startfields select.hourmenu', 'div.re-startfields select.minmenu', 'div.re-endfields input.year','div.re-endfields select.monthmenu', 'div.re-endfields select.daymenu', 'div.re-endfields select.hourmenu','div.re-endfields select.minmenu' ]; var lfx = flist.length; for ( var fx=0; fx= vlist.length ) { vlist[fx] = ""; } if ( vlist[fx] !== "" ) { $( flist[fx], container ).val( vlist[fx] ); } } /* Enable date fields if month spec present */ $('.datespec', container).prop('disabled', vlist[1]===""); $("select", container).on( 'change.reactor', handleConditionRowChange ); $("input", container).on( 'change.reactor', handleConditionRowChange ); break; case 'interval': fs = $( '
' ).appendTo( container ); el = $( '' ); el.append( '' ); el.append( ' days ' ); fs.append( el ); fs.append( " " ); el = $( '' ); el.append( '' ); el.append( ' hours ' ); fs.append( el ); fs.append( " " ); el = $( ''); el.append( '' ); el.append( ' minutes '); fs.append( el ); container.append( " " ); /* Interval relative time or condition (opposing divs) */ el = $( '' ).text( " relative to "); mm = $( '' ); mm.append( $( '' ).val( "" ).text( "Time" ) ); mm.append( $( '' ).val( "condtrue" ).text( "Condition TRUE" ) ); el.append( mm ); container.append( el ); fs = $( '
' ); fs.append(''); mm = $('').appendTo( fs ); for ( k=1; k<=31; k++) { $( '' ).val( k ).text( k ).appendTo( mm ); } mm = $(''); for ( k=0; k<24; k++ ) { v = ( k < 10 ? "0" : "" ) + String(k); mm.append( $('').val( v ).text( v ) ); } fs.append( mm ); fs.append(" : "); mm = $(''); for ( k=0; k<60; k+=5 ) { v = ( k < 10 ? "0" : "" ) + String(k); mm.append( $('').val( v ).text( v ) ); } fs.append( mm ); container.append( fs ); fs = $( '
' ).hide(); mm = $( '' ); mm.append( $( '' ).val( "" ).text( '--choose--' ) ); DOtraverse( getConditionIndex().root, function( n ) { var tt = (condTypeName[n.type || "group"] || "?") + ": " + makeConditionDescription( n ) + " <" + String(n.id) + ">"; mm.append( $( '' ).val( n.id ).text( tt ) ); }, false, function( n ) { return "comment" !== n.type && n.id != cond.id && !isAncestor( n.id, cond.id ); }); fs.append( mm ); container.append( fs ); $( ".re-days", container ).val( cond.days || 0 ); $( ".re-hours", container ).val( cond.hours===undefined ? 1 : cond.hours ); $( ".re-mins", container ).val( cond.mins || 0 ); $( "select.re-relto", container ).val( cond.relto || "" ); if ( "condtrue" === cond.relto ) { /* Relative to condition */ $( "div.re-relcondset", container ).show(); $( "div.re-reltimeset", container ).hide(); var t = cond.relcond || ""; menuSelectDefaultInsert( $( "select.re-relcond", container ), t ); } else { /* Relative to time (default) */ if ( ! isEmpty( cond.basetime ) ) { mm = cond.basetime.split(/,/); menuSelectDefaultInsert( $( '.re-relhour', container ), mm[0] || '00' ); menuSelectDefaultInsert( $( '.re-relmin', container ), mm[1] || '00' ); } if ( ! isEmpty( cond.basedate ) ) { mm = cond.basedate.split(/,/); $( 'input.re-relyear', container ).val( mm[0] ); menuSelectDefaultFirst( $( '.re-relmon', container ), mm[1] || "1" ); menuSelectDefaultFirst( $( '.re-relday', container ), mm[2] || "1" ); } else { $( '.re-reldate', container ).prop( 'disabled', true ); } } $("select,input", container).on( 'change.reactor', function( ev ) { var $el = $( ev.currentTarget ); var $row = $el.closest( 'div.cond-container' ); if ( $el.hasClass( "re-relto" ) ) { var relto = $el.val() || ""; if ( "condtrue" === relto ) { $( '.re-reltimeset', $row ).hide(); $( '.re-relcondset', $row ).show(); /* Rebuild the menu of conditions, in case changed */ var $mm = $( 'select.re-relcond', $row ); $( 'option[value!=""]', $mm ).remove(); DOtraverse( getConditionIndex().root, function( n ) { $mm.append( $( '' ).val( n.id ).text( makeConditionDescription( n ) ) ); }, false, function( n ) { return "comment" !== n.type && n.id != cond.id && !isAncestor( n.id, cond.id ); }); $mm.val( "" ); } else { $( '.re-reltimeset', $row ).show(); $( '.re-relcondset', $row ).hide(); } } handleConditionRowChange( ev ); /* pass on */ } ); break; case 'ishome': container.append( ''); mm = $( '' ); mm.append( $( '' ).val("").text('--choose user--') ); fs = $( '
' ); for ( k in userIx ) { if ( userIx.hasOwnProperty( k ) ) { getCheckbox( cond.id + '-user-' + k, k, userIx[k].name || k, "useropt" ) .appendTo( fs ); mm.append( $( '' ).val( k ).text( ( userIx[k] || {} ).name || k ) ); } } container.append( fs ); fs = $( '
' ); fs.append( mm ); fs.append( '' ); container.append( fs ); if ( !unsafeLua ) { $( '
It is recommended that "Allow Unsafe Lua" (Users & Account Info > Security) be enabled when using this condition. Otherwise, less efficient methods of acquiring the geofence data must be used and may impact system performance. This setting is currently disabled.
' ) .appendTo( container ); } $("input.useropt", container).on( 'change.reactor', handleConditionRowChange ); $("select.geofencecond", container) .on( 'change.reactor', handleGeofenceOperatorChange ); op = menuSelectDefaultFirst( $( "select.geofencecond", container ), cond.operator ); $("select.re-userid", container).on( 'change.reactor', handleGeofenceUserChange ); $("select.re-location", container).on( 'change.reactor', handleConditionRowChange ); if ( op === "at" || op === "notat" ) { $( 'div.re-geoquick', container ).hide(); $( 'div.re-geolong', container ).show(); mm = ( cond.value || "" ).split(','); if ( mm.length > 0 ) { menuSelectDefaultInsert( $( 'select.re-userid', container ), mm[0] ); updateGeofenceLocations( container, mm[1] ); } } else { $( 'div.re-geoquick', container ).show(); $( 'div.re-geolong', container ).hide(); (cond.value || "").split(',').forEach( function( val ) { if ( ! isEmpty( val ) ) { var $c = $('input.useropt[value="' + val + '"]', container); if ( 0 === $c.length ) { $c = getCheckbox( cond.id + '-user-' + val, val, val + "? (unknown user)", "useropt" ); $c.appendTo( $( 'div.re-geoquick', container ) ); } $c.prop('checked', true); } }); } break; case 'reload': /* no fields */ break; default: /* nada */ } /* Set up display of condition options. Not all conditions have * options, and those that do don't have all options. Clear the UI * each time, so it's rebuilt as needed. */ $( 'div.condopts', row ).remove(); var btn = $( 'button.re-condmore', row ); if ( condOptions[ cond.type ] ) { btn.prop( 'disabled', false ).show(); if ( hasAnyProperty( cond.options ) ) { btn.addClass( 'attn' ); } else { btn.removeClass( 'attn' ); delete cond.options; } } else { btn.removeClass( 'attn' ).prop( 'disabled', true ).hide(); delete cond.options; } } /** * Type menu selection change handler. */ function handleTypeChange( ev ) { var $el = $( ev.currentTarget ); var newType = $el.val(); var $row = $el.closest( 'div.cond-container' ); var condId = $row.attr( 'id' ); var ixCond = getConditionIndex(); if ( newType !== ixCond[condId].type ) { /* Change type */ removeConditionProperties( ixCond[condId], "type" ); ixCond[condId].type = newType; ixCond[condId].options = {}; /* must clear on type change */ setConditionForType( ixCond[condId], $row ); $row.addClass( 'tbmodified' ); configModified = true; updateConditionRow( $row ); } } /** * Handle click on Add Condition button. */ function handleAddConditionClick( ev ) { var $el = $( ev.currentTarget ); var $parentGroup = $el.closest( 'div.cond-container' ); var parentId = $parentGroup.attr( 'id' ); /* Create a new condition in data, assign an ID */ var cond = { id: getUID("cond"), type: "comment" }; // ??? /* Insert new condition in UI */ var condel = getConditionTemplate( cond.id ); $( 'select.re-condtype', condel ).val( cond.type ); setConditionForType( cond, condel ); $( 'div.cond-list:first', $parentGroup ).append( condel ); /* Add to data */ var ixCond = getConditionIndex(); var grp = ixCond[ parentId ]; grp.conditions.push( cond ); cond.__parent = grp; ixCond[ cond.id ] = cond; reindexConditions( grp ); condel.addClass( 'tbmodified' ); configModified = true; updateConditionRow( condel ); $( 'select.re-condtype', condel ).focus(); } function shallowcopy( o ) { var res = {}; for ( var k in o ) { if ( o.hasOwnProperty( k ) ) { res[k] = o[k]; } } return res; } /** * Import group into current group */ function handleImportGroupClick( event ) { var grpid = $( event.currentTarget ).data( 'id' ); var $insgrpel = $( event.currentTarget ).closest( '.cond-group' ); var insgrpid = $insgrpel.attr( 'id' ); var ixCond = getConditionIndex(); var insgrp = ixCond[ insgrpid ]; /** First, (deep) copy structure to temporary (to prevent infinite * recursion if some wise guy like me copies a parent into a child). * Assign new IDs to each condition. Note we make no attempt to "fix" * options when copying, like sequence restrictions. */ var cp = function( s, d ) { (s.conditions || []).forEach( function( node ) { var c = shallowcopy( node ); c.id = getUID( isGroup( c ) ? 'grp' : 'cond' ); ixCond[ c.id ] = c; d.conditions.push( c ); if ( isGroup( node ) ) { c.conditions = []; c.name = c.name + ' Copy'; cp( node, c ); } }); }; var t = { conditions: [] }; cp( ixCond[ grpid ], t ); /* Append the copied conditions */ insgrp.conditions = (insgrp.conditions || []).concat( t.conditions ); /* Now redraw the recipient group */ var $parent = $insgrpel.parent(); $insgrpel.remove(); redrawGroup( false, insgrp, $parent, insgrp.__depth ); reindexConditions( insgrp ); refreshGroupMenus(); configModified = true; updateSaveControls(); } /** * Refresh group list for import */ function refreshGroupMenus() { var cdata = getConfiguration(); var ix = getConditionIndex(); /* Ensure depths are computed */ var $ul = $( '
    ' ); DOtraverse( cdata.conditions.root, function( node ) { $( '
  • ') .addClass( 'dropdown-item' ) .data( 'id', node.id ).attr( 'data-group', node.id ) .html( (" ").repeat( 2*(node.__depth||0) ) + ( node.name || node.id ) ) .appendTo( $ul ); }, false, isGroup ); $( 'ul.dropdown-menu.re-condgroup-menu' ).empty().append( $ul.children() ); $( 'ul.dropdown-menu.re-condgroup-menu li' ).on( 'click.reactor', handleImportGroupClick ); } function handleTitleChange( ev ) { var input = $( ev.currentTarget ); var grpid = input.closest( 'div.cond-container.cond-group' ).attr( 'id' ); var newname = (input.val() || "").trim(); var span = $( 'span.re-title', input.parent() ); var grp = getConditionIndex()[grpid]; input.removeClass( 'tberror' ); if ( newname !== grp.name ) { /* Group name check */ if ( newname.length < 1 ) { ev.preventDefault(); $( 'button.saveconf' ).prop( 'disabled', true ); input.addClass( 'tberror' ); input.focus(); return; } /* Update config */ input.closest( 'div.cond-group' ).addClass( 'tbmodified' ); grp.name = newname; configModified = true; refreshGroupMenus(); } /* Remove input field and replace text */ input.remove(); span.text( newname ); span.closest( 'div.cond-group-title' ).children().show(); updateSaveControls(); } function handleTitleClick( ev ) { /* N.B. Click can be on span or icon */ var $el = $( ev.currentTarget ); var $p = $el.closest( 'div.cond-group-title' ); $p.children().hide(); var grpid = $p.closest( 'div.cond-container.cond-group' ).attr( 'id' ); var grp = getConditionIndex()[grpid]; if ( grp ) { $p.append( $( '' ) .val( grp.name || grp.id || "" ) ); $( 'input.titleedit', $p ).on( 'change.reactor', handleTitleChange ) .on( 'blur.reactor', handleTitleChange ) .focus(); } } /** * Handle click on group expand/collapse. */ function handleGroupExpandClick( ev ) { var $el = $( ev.currentTarget ); var $p = $el.closest( 'div.cond-container.cond-group' ); var $l = $( 'div.cond-group-body:first', $p ); if ( $el.hasClass( 're-collapse' ) ) { $l.slideUp(); $el.addClass( 're-expand' ).removeClass( 're-collapse' ).attr( 'title', 'Expand group' ); $( 'i', $el ).text( 'expand_more' ); try { var n = $( 'div.cond-list:first > div', $p ).length; $( 'span.re-titlemessage:first', $p ).text( " (" + n + " condition" + ( 1 !== n ? "s" : "" ) + " collapsed)" ); } catch( e ) { $( 'span.re-titlemessage:first', $p ).text( " (conditions collapsed)" ); } } else { $l.slideDown(); $el.removeClass( 're-expand' ).addClass( 're-collapse' ).attr( 'title', 'Collapse group' ); $( 'i', $el ).text( 'expand_less' ); $( 'span.re-titlemessage:first', $p ).text( "" ); } } /** * Handle click on group focus--collapses all groups except clicked */ function handleGroupFocusClick( ev ) { var $el = $( ev.currentTarget ); var $p = $el.closest( 'div.cond-container.cond-group' ); var $l = $( 'div.cond-group-body:first', $p ); var focusGrp = getConditionIndex()[$p.attr('id')]; var $btn = $( 'button.re-expand', $p ); if ( $btn.length > 0 ) { $l.slideDown(); $btn.removeClass( 're-expand' ).addClass( 're-collapse' ).attr( 'title', 'Collapse group' ); $( 'i', $btn ).text( 'expand_less' ); $( 'span.re-titlemessage:first', $p ).text( "" ); } function hasCollapsedParent( grp ) { // var parent = grp.__parent; return false; } /* collapse: descendents of this group -- NO ancestors of this group -- NO siblings of this group -- YES none of the above -- YES */ DOtraverse( getConfiguration().conditions.root, function( node ) { var gid = node.id; var $p = $( 'div#' + idSelector( gid ) + ".cond-group" ); var $l = $( 'div.cond-group-body:first', $p ); var $btn = $( 'button.re-collapse', $p ); if ( $btn.length > 0 && !hasCollapsedParent( focusGrp ) ) { $l.slideUp(); $( 'button.re-collapse', $p ).removeClass( 're-collapse' ).addClass( 're-expand' ).attr( 'title', 'Expand group'); $( 'i', $btn ).text( 'expand_more' ); try { var n = $( 'div.cond-list:first > div', $p ).length; $( 'span.re-titlemessage:first', $p ).text( " (" + n + " condition" + ( 1 !== n ? "s" : "" ) + " collapsed)" ); } catch( e ) { $( 'span.re-titlemessage:first', $p ).text( " (conditions collapsed)" ); } } }, false, function( node ) { /* Filter out non-groups, focusGrp, and nodes that are neither ancestors nor descendents of focusGrp */ return isGroup( node ) && node.id !== focusGrp.id && ! ( isAncestor( node.id, focusGrp.id ) || isDescendent( node.id, focusGrp.id ) ); } ); } /** * Delete condition. If it's a group, delete it and all children * recursively. */ function deleteCondition( condId, ixCond, cdata, pgrp, reindex ) { var cond = ixCond[condId]; if ( undefined === cond ) return; pgrp = pgrp || cond.__parent; if ( undefined === reindex ) reindex = true; /* Remove references to this cond in sequences */ for ( var ci in ixCond ) { if ( ixCond.hasOwnProperty( ci ) && (ixCond[ci].options || {}).after === condId ) { delete ixCond[ci].options.after; delete ixCond[ci].options.aftertime; delete ixCond[ci].options.aftermode; } } /* If this condition is a group, delete all subconditions (recursively) */ if ( "group" === ( cond.type || "group" ) ) { var lx = cond.conditions ? cond.conditions.length : 0; /* Delete end to front to avoid need to reindex each time */ for ( var ix=lx-1; ix>=0; ix-- ) { deleteCondition( cond.conditions[ix].id, ixCond, cdata, cond, false ); } /* Remove related activities */ if ( (cond.activities || {})[condId + ".true"] ) { delete cond.activities[condId + ".true"]; } if ( (cond.activities || {})[condId + ".false"] ) { delete cond.activities[condId + ".false"]; } } /* Remove from index, and parent group, possibly reindex */ pgrp.conditions.splice( cond.__index, 1 ); delete ixCond[condId]; if ( reindex ) { reindexConditions( pgrp ); } configModified = true; } /** * Handle delete group button click */ function handleDeleteGroupClick( ev ) { var $el = $( ev.currentTarget ); if ( $el.prop( 'disabled' ) || "root" === $el.attr( 'id' ) ) { return; } var $grpEl = $el.closest( 'div.cond-container.cond-group' ); var grpId = $grpEl.attr( 'id' ); var ixCond = getConditionIndex(); var grp = ixCond[ grpId ]; /* Confirm deletion only if group is not empty */ if ( ( grp.conditions || [] ).length > 0 && ! confirm( 'This group has conditions and/or sub-groups, which will all be deleted as well. Really delete this group?' ) ) { return; } $grpEl.remove(); deleteCondition( grpId, ixCond, getConfiguration(), grp.__parent, true ); refreshGroupMenus(); configModified = true; $el.closest( 'div.cond-container.cond-group' ).addClass( 'tbmodified' ); // ??? NO! Parent group! updateSaveControls(); } /** * Handle click on Add Group button. */ function handleAddGroupClick( ev ) { var $el = $( ev.currentTarget ); /* Create a new condition group div, assign a group ID */ var newId = getUID("grp"); var $condgroup = getGroupTemplate( newId ); /* Create an empty condition group in the data */ var $parentGroup = $el.closest( 'div.cond-container.cond-group' ); var $container = $( 'div.cond-list:first', $parentGroup ); var parentId = $parentGroup.attr( 'id' ); var ixCond = getConditionIndex(); var grp = ixCond[ parentId ]; var newgrp = { id: newId, name: newId, operator: "and", type: "group", conditions: [] }; newgrp.__parent = grp; newgrp.__index = grp.conditions.length; newgrp.__depth = ( grp.__depth || 0 ) + 1; grp.conditions.push( newgrp ); ixCond[ newId ] = newgrp; /* Append the new condition group to the container */ $container.append( $condgroup ); $condgroup.addClass( 'level' + newgrp.__depth ).addClass( 'levelmod' + (newgrp.__depth % 4) ); $condgroup.addClass( 'tbmodified' ); refreshGroupMenus(); configModified = true; updateSaveControls(); } /** * Handle click on the condition delete tool */ function handleConditionDelete( ev ) { var el = $( ev.currentTarget ); var row = el.closest( 'div.cond-container' ); var condId = row.attr('id'); if ( el.prop( 'disabled' ) ) { return; } /* See if the condition is referenced in a sequence */ var okDelete = false; var ixCond = getConditionIndex(); for ( var ci in ixCond ) { if ( ixCond.hasOwnProperty(ci) && ( ixCond[ci].options || {} ).after == condId ) { if ( !okDelete ) { if ( ! ( okDelete = confirm('This condition is used in sequence options in another condition. Click OK to delete it and disconnect the sequence, or Cancel to leave everything unchanged.') ) ) { return; } } delete ixCond[ci].options.after; delete ixCond[ci].options.aftertime; delete ixCond[ci].options.aftermode; } } deleteCondition( condId, ixCond, getConfiguration(), ixCond[condId].__parent, true ); /* Remove the condition row from display, reindex parent. */ row.remove(); el.closest( 'div.cond-container.cond-group' ).addClass( 'tbmodified' ); configModified = true; updateSaveControls(); } /** * Receive a node at the end of a drag/drop (list-to-list move). */ function handleNodeReceive( ev, ui ) { var $el = $( ui.item ); var $target = $( ev.target ); /* receiving .cond-list */ // var $from = $( ui.sender ); var ixCond = getConditionIndex(); /* Now, disconnect the data object from its current parent */ var obj = ixCond[ $el.attr( 'id' ) ]; obj.__parent.conditions.splice( obj.__index, 1 ); reindexConditions( obj.__parent ); /* Attach it to new parent. */ var prid = $target.closest( 'div.cond-container.cond-group' ).attr( 'id' ); var pr = ixCond[prid]; pr.conditions.push( obj ); /* doesn't matter where we put it */ obj.__parent = pr; /* Don't get fancy, just reindex as it now appears. */ reindexConditions( pr ); $el.addClass( 'tbmodified' ); /* ??? Is this really what we want to flag? */ configModified = true; updateSaveControls(); } function handleNodeUpdate( ev, ui ) { var $el = $( ui.item ); var $target = $( ev.target ); /* receiving .cond-list */ // var $from = $( ui.sender ); var ixCond = getConditionIndex(); /* UI is handled, so just reindex parent */ var prid = $target.closest( 'div.cond-container.cond-group' ).attr( 'id' ); var pr = ixCond[prid]; reindexConditions( pr ); $el.addClass( 'tbmodified' ); /* ??? Is this really what we want to flag? */ configModified = true; updateSaveControls(); } /** * Does activity have actions? */ function activityHasActions( act, cdata ) { var scene = (cdata.activities||{})[act]; /* Check, first group has actions or delay > 0 */ return scene && (scene.groups||[]).length > 0 && ( (scene.groups[0].actions||[]).length > 0 || (scene.groups[0].delay||0) > 0 ); } /** * Does group have activities? */ function groupHasActivities( grp, cdata ) { cdata = cdata || getConfiguration(); return activityHasActions( grp.id+'.true', cdata ) || activityHasActions( grp.id+'.false', cdata ); } /** * Handle click on group controls (NOT/AND/OR/XOR/NUL) */ function handleGroupControlClick( ev ) { var $el = $( ev.target ); var grpid = $el.closest( 'div.cond-container.cond-group' ).attr( 'id' ); var grp = getConditionIndex()[ grpid ]; /* Generic handling */ if ( $el.closest( '.btn-group' ).hasClass( 'tb-btn-radio' ) ) { $el.closest( '.btn-group' ).find( '.checked' ).removeClass( 'checked' ); $el.addClass( 'checked' ); } else { $el.toggleClass( "checked" ); } if ( $el.hasClass( 're-op-not' ) ) { grp.invert = $el.hasClass( "checked" ); } else if ( $el.hasClass( 're-disable' ) ) { grp.disabled = $el.hasClass( "checked" ); } else { var opScan = [ "re-op-and", "re-op-or", "re-op-xor", "re-op-nul" ]; var lx = opScan.length; for ( var ix=0; ix < lx; ix++ ) { var cls = opScan[ix]; if ( $el.hasClass( cls ) && $el.hasClass( "checked" ) ) { /* Special case handling for NUL--remove activities, force no NOT */ if ( "re-op-nul" === cls ) { var cdata = getConfiguration(); if ( groupHasActivities( grp, cdata ) && ! confirm( 'This group currently has Activities associated with it. Groups with the NUL operator do not run Activities. OK to delete the associated Activities?' ) ) { return; } delete cdata.activities[grpid+'.true']; delete cdata.activities[grpid+'.false']; } grp.operator = cls.replace( /^re-op-/, "" ); break; } } } if ( false === grp.disabled ) delete grp.disabled; if ( false === grp.invert ) delete grp.invert; $el.closest( 'div.cond-container.cond-group' ).addClass( 'tbmodified' ); configModified = true; updateSaveControls(); } /** * Create an empty condition row. Only type selector is pre-populated. */ function getConditionTemplate( id ) { var el = $( '\
    \
    \ \ \ \
    \
    \
    \ \
    \
    \
    \
    ' ); [ "comment", "service", "grpstate", "var", "housemode", "sun", "weekday", "trange", "interval", "ishome", "reload" ].forEach( function( k ) { if ( ! ( isOpenLuup && k == "ishome" ) ) { $( "select.re-condtype", el ).append( $( "" ).val( k ).text( condTypeName[k] ) ); } }); el.attr( 'id', id ); $('select.re-condtype', el).on( 'change.reactor', handleTypeChange ); $('button.re-delcond', el).on( 'click.reactor', handleConditionDelete ); $("button.re-condmore", el).on( 'click.reactor', handleExpandOptionsClick ); return el; } function getGroupTemplate( grpid ) { var el = $( '\
    \
    \
    \ \ \ \
    \
    \
    \ \
    \
    \ \ \ \ \
    \
    \ \
    \
    \ \ \ \ \ \
    \
    \
    \
    \
    \
    \
    \ \ \
    \ \ \
    \
    \
    \
    ' ); el.attr('id', grpid); $( 'span.re-title', el ).text( grpid ); $( 'div.cond-group-conditions input[type="radio"]', el ).attr('name', grpid); if ( 'root' === grpid ) { /* Can't delete root group, but use the space for Save and Revert */ $( 'button.re-delgroup', el ).replaceWith( $( ' ' ) ); /* For root group, remove all elements with class noroot */ $( '.noroot', el ).remove(); } $( 'button.re-focus', el ).prop( 'disabled', true ).hide(); /* TODO: for now */ $( 'button.re-addcond', el ).on( 'click.reactor', handleAddConditionClick ); $( 'button.re-addgroup', el ).on( 'click.reactor', handleAddGroupClick ); $( 'button.re-delgroup', el ).on( 'click.reactor', handleDeleteGroupClick ); $( 'button.re-condmore', el).on( 'click.reactor', handleExpandOptionsClick ); $( 'span.re-title,button.re-edittitle', el ).on( 'click.reactor', handleTitleClick ); $( 'button.re-collapse', el ).on( 'click.reactor', handleGroupExpandClick ); $( 'button.re-focus', el ).on( 'click.reactor', handleGroupFocusClick ); $( '.cond-group-control > button', el ).on( 'click.reactor', handleGroupControlClick ); $( '.cond-list', el ).addClass("tb-sortable").sortable({ helper: 'clone', handle: '.draghandle', cancel: '', /* so draghandle can be button */ items: '> *:not([id="root"])', // containment: 'div.cond-list.tb-sortable', connectWith: 'div.cond-list.tb-sortable', /* https://stackoverflow.com/questions/15724617/jQuery-dragmove-but-leave-the-original-if-ctrl-key-is-pressed start: function( ev, ui ) { if ( ev.ctrlKey ) { $clone = ui.item.clone().insertBefore( ui.item ); $clone.css({position:"static"}); } }, */ receive: handleNodeReceive, /* between cond-lists */ update: handleNodeUpdate /* within one cond-list */ }); return el; } redrawGroup = function( myid, grp, container, depth ) { container = container || $( 'div#conditions' ); depth = depth || grp.__depth || 0; var el = getGroupTemplate( grp.id ); container.append( el ); el.addClass( 'level' + depth ).addClass( 'levelmod' + (depth % 4) ); $( 'span.re-title', el ).text( grp.name || grp.id ).attr( 'title', msgGroupIdChange ); $( 'div.cond-group-conditions .tb-btn-radio button', el ).removeClass( "checked" ); $( 'div.cond-group-conditions .tb-btn-radio button.re-op-' + ( grp.operator || "and" ), el ).addClass( "checked" ); if ( grp.invert ) { $( 'div.cond-group-conditions button.re-op-not', el ).addClass( "checked" ); } else { delete grp.invert; } if ( grp.disabled ) { $( 'div.cond-group-conditions button.re-disable', el ).addClass( "checked" ); } else { delete grp.disabled; } if ( grp.options && hasAnyProperty( grp.options ) ) { $( 'button.re-condmore', el ).addClass( 'attn' ); } container = $( 'div.cond-list', el ); var lx = grp.conditions ? grp.conditions.length : 0; for ( var ix=0; ix' + (condTypeName[cond.type] === undefined ? cond.type + ' (deprecated)' : condTypeName[cond.type] ) + ''); } $('select.re-condtype', row).val( cond.type ); setConditionForType( cond, row ); } else { /* Group! */ redrawGroup( myid, cond, container, depth + 1 ); } } }; /** * Redraw the conditions from the current cdata */ function redrawConditions( myid ) { var container = $("div#conditions"); container.empty(); var cdata = getConfiguration( myid ); redrawGroup( myid, cdata.conditions.root ); refreshGroupMenus(); $( 'div.cond-cond', container ).has( '.tberror' ).addClass( 'tberror' ); $("button.saveconf").on( 'click.reactor', handleSaveClick ); $("button.revertconf").on( 'click.reactor', handleRevertClick ); updateSaveControls(); /* Clear unused state variables here so that we catch ReactorGroup * service, for which the function requires ixCond. */ clearUnusedStateVariables( myid, cdata ); } function startCondBuilder() { var myid = api.getCpanelDeviceId(); redrawConditions( myid ); if ( 0 !== parseInt( getParentState( "DefaultCollapseConditions", myid ) || "0" ) ) { $( 'div.reactortab .cond-group-title button.re-collapse').trigger( 'click' ); } captureControlPanelClose( $('div.reactortab') ); } /* Public interface */ console.log("Initializing ConditionBuilder module"); myModule = { init: function( dev ) { return initModule( dev ); }, start: startCondBuilder, redraw: redrawConditions, makeVariableMenu: makeVariableMenu }; return myModule; })( api, jQuery ); function doConditions() { console.log("doConditions()"); try { var myid = api.getCpanelDeviceId(); checkUnsaved( myid ); if ( ! CondBuilder.init( myid ) ) { return; } header(); /* Our styles. */ if ( 0 === $('style#reactor-condition-styles').length ) { $('head').append( ''); } /* Body content */ var html = '
    '; html += '

    Conditions

    '; var rr = api.getDeviceState( myid, serviceId, "Retrigger" ) || "0"; if ( rr !== "0" ) { html += '
    WARNING! Retrigger is on! You should avoid using time-related conditions in this ReactorSensor, as they may cause frequent retriggers!
    '; } html += '
    '; html += '
    '; /* #tab-conds */ html += footer(); api.setCpanelContent(html); if ( checkRemoteAccess() ) { $( 'div.reactortab' ).prepend( $( '
    ' ).text( msgRemoteAlert ) ); } /* Set up a data list with our variables */ var cd = getConfiguration( myid ); var dl = $(''); if ( cd.variables ) { for ( var vname in cd.variables ) { if ( cd.variables.hasOwnProperty( vname ) ) { var opt = $( '' ).val( '{'+vname+'}' ).text( '{'+vname+'}' ); dl.append( opt ); } } } $( 'div#tab-conds.reactortab' ).append( dl ); CondBuilder.start( myid ); } catch (e) { console.log( 'Error in ReactorSensor.doConditions(): ' + String( e ) ); console.log( e ); alert( e.stack ); } } /** *************************************************************************** * * E X P R E S S I O N S * ** **************************************************************************/ function updateVariableControls() { var container = $('div#reactorvars'); var errors = $('.tberror', container); $("button.saveconf", container).prop('disabled', ! ( configModified && errors.length === 0 ) ); $("button.revertconf", container).prop('disabled', !configModified); } function handleVariableChange( ev ) { var container = $('div#reactorvars'); var cd = getConfiguration(); $('.tberror', container).removeClass( 'tberror' ); $('div.varexp', container).each( function( ix, obj ) { var row = $(obj); var vname = row.attr("id"); if ( undefined === vname ) return; var expr = ( $('textarea.expr', row).val() || "" ).trim(); expr = expr.replace( /^=+\s*/, "" ); /* Remove leading =, this isn't Excel people */ $( 'textarea.expr', row ).val( expr ); if ( cd.variables[vname] === undefined ) { cd.variables[vname] = { name: vname, expression: expr, index: ix }; configModified = true; } else { if ( cd.variables[vname].expression !== expr ) { cd.variables[vname].expression = expr; configModified = true; } if ( cd.variables[vname].index !== ix ) { cd.variables[vname].index = ix; configModified = true; } } var exp = $( 'button.re-export', row ).hasClass( 'attn' ) ? undefined : 0; if ( cd.variables[vname].export !== exp ) { if ( 0 === exp ) { cd.variables[vname].export = 0; } else { delete cd.variables[vname].export; /* default is export */ } configModified = true; } }); updateVariableControls(); } function handleTryExprClick( ev ) { var row = $( ev.currentTarget ).closest( "div.varexp" ); $.ajax({ url: api.getDataRequestURL(), data: { id: "lr_Reactor", action: "tryexpression", device: api.getCpanelDeviceId(), expr: $( 'textarea.expr', row ).val() || "", r: Math.random() }, dataType: "json", cache: false, timeout: 5000 }).done( function( data, statusText, jqXHR ) { var msg; if ( data.err ) { msg = 'There is an error in the expression'; if ( data.err.location ) { $('textarea.expr', row).focus().prop('selectionStart', data.err.location); msg += ' at ' + String( data.err.location ); } msg += ': ' + data.err.message; } else { msg = "The expression result is: " + String( data.resultValue ) + ' (' + typeof( data.resultValue ) + ')'; } alert( msg ); }).fail( function( jqXHR ) { alert( "There was an error making the request. Vera may be busy; try again in a moment." ); }); } function handleDeleteVariableClick( ev ) { var row = $( ev.currentTarget ).closest( 'div.varexp' ); var vname = row.attr('id'); if ( confirm( 'Deleting "' + vname + '" will break conditions, actions, or other expressions that use it.' ) ) { var cdata = getConfiguration(); delete cdata.variables[vname]; row.remove(); configModified = true; updateVariableControls(); } } function clearGetStateOptions() { var container = $('div#reactorvars'); var row = $( 'div#opt-state', container ); row.remove(); $( 'button#addvar', container ).prop( 'disabled', false ); $( 'textarea.expr,button.md-btn', container ).prop( 'disabled', false ); } function handleGetStateClear( ev ) { ev.preventDefault(); clearGetStateOptions(); } function handleGetStateInsert( ev ) { var row = $( ev.currentTarget ).closest( 'div.row' ); var device = $( 'select#gsdev', row ).val() || "-1"; var service = $( 'select#gsvar', row ).val() || ""; var variable = service.replace( /^[^\/]+\//, "" ); service = service.replace( /\/.*$/, "" ); if ( "-1" === device ) { device = "null"; } else if ( $( 'input#usename', row ).prop( 'checked' ) ) { device = '"' + $( 'select#gsdev option:selected' ).text().replace( / +\(#\d+\)$/, "" ) + '"'; } var str = ' getstate( ' + device + ', "' + service + '", "' + variable + '" ) '; var varrow = row.prev(); var f = $( 'textarea.expr', varrow ); var expr = f.val() || ""; var p = f.get(0).selectionEnd || -1; if ( p >= 0 ) { expr = expr.substring(0, p) + str + expr.substring(p); } else { expr = str + expr; } expr = expr.trim(); f.val( expr ); f.removeClass( 'tberror' ); var vname = varrow.attr("id"); var cd = getConfiguration(); if ( cd.variables[vname] === undefined ) { cd.variables[vname] = { name: vname, expression: expr }; } else { cd.variables[vname].expression = expr; } configModified = true; clearGetStateOptions(); updateVariableControls(); } function handleGetStateOptionChange( ev ) { var row = $( ev.currentTarget ).closest( 'div.row' ); var f = $( ev.currentTarget ); if ( f.attr( 'id' ) == "gsdev" ) { var device = parseInt( f.val() || "-1" ); var s = CondBuilder.makeVariableMenu( device, "", "" ).attr( 'id', 'gsvar' ); $( 'select#gsvar', row ).replaceWith( s ); /* Switch to new varmenu */ f = $( 'select#gsvar', row ); f.on( 'change.reactor', handleGetStateOptionChange ); } $( 'button#getstateinsert', row ).prop( 'disabled', "" === f.val() ); } function handleGetStateClick( ev ) { var row = $( ev.currentTarget ).closest( 'div.varexp' ); var container = $('div#reactorvars'); $( 'button#addvar', container ).prop( 'disabled', true ); $( 'button.md-btn', container ).prop( 'disabled', true ); $( 'textarea.expr', row ).prop( 'disabled', false ); /* Remove any prior getstates */ $('div#opt-state').remove(); var el = $( '
    ' ); el.append( makeDeviceMenu( "", "" ).attr( 'id', 'gsdev' ) ); el.append( CondBuilder.makeVariableMenu( parseInt( $( 'select#gsdev', el ).val() ), "", "" ) .attr( 'id', 'gsvar' ) ); el.append(' '); el.append( '' ); el.append(' '); el.append( $( '' ).attr( 'id', 'getstateinsert' ) .addClass( "btn btn-xs btn-success" ) .text( 'Insert' ) ); el.append( $( '' ).attr( 'id', 'getstatecancel' ) .addClass( "btn btn-xs btn-default" ) .text( 'Cancel' ) ); $( '
    ' ).append( el ).insertAfter( row ); $( 'select.devicemenu', el ).on( 'change.reactor', handleGetStateOptionChange ); $( 'button#getstateinsert', el ).prop( 'disabled', true ) .on( 'click.reactor', handleGetStateInsert ); $( 'button#getstatecancel', el ).on( 'click.reactor', handleGetStateClear ); $( 'button.saveconf' ).prop( 'disabled', true ); } function handleExportClick( ev ) { var $el = $( ev.currentTarget ); if ( $el.hasClass( 'attn' ) ) { /* Turn off export */ $el.removeClass( 'attn' ).attr( 'title', 'Result not exported to state variable' ); } else { $el.addClass( 'attn' ).attr( 'title', 'Result exports to state variable' ); } /* Pass it on */ handleVariableChange( ev ); } function getVariableRow() { var el = $('
    '); el.append( '
    ' ); el.append( '
    '); // ??? devices_other is an alternate for insert state variable el.append( '
    \ \ \ \ \ \
    ' ); $( 'textarea.expr', el ).prop( 'disabled', true ).on( 'change.reactor', handleVariableChange ); $( 'button.re-export', el ).prop( 'disabled', true ).on( 'click.reactor', handleExportClick ); $( 'button.re-tryexpr', el ).prop( 'disabled', true ).on( 'click.reactor', handleTryExprClick ); $( 'button.re-getstate', el ).prop( 'disabled', true ).on( 'click.reactor', handleGetStateClick ); $( 'button.re-deletevar', el ).prop( 'disabled', true ).on( 'click.reactor', handleDeleteVariableClick ); $( 'button.draghandle', el ).prop( 'disabled', true ); return el; } function handleAddVariableClick() { var container = $('div#reactorvars'); $( 'button#addvar', container ).prop( 'disabled', true ); $( 'div.varexp textarea.expr,button.md-btn', container ).prop( 'disabled', true ); var editrow = getVariableRow(); $( 'div.re-varname', editrow ).empty().append( '' ); $( 'div.re-varname input', editrow ).on('change.reactor', function( ev ) { /* Convert to regular row */ var f = $( ev.currentTarget ); var row = f.closest( 'div.varexp' ); var vname = (f.val() || "").trim(); if ( vname === "" || $( 'div.varexp#' + idSelector( vname ) ).length > 0 || !vname.match( /^[A-Z][A-Z0-9_]*$/i ) ) { row.addClass( 'tberror' ); f.addClass('tberror'); f.focus(); } else { row.attr('id', vname).removeClass('editrow').removeClass('tberror'); $( '.tberror', row ).removeClass('tberror'); /* Remove the name input field and swap in the name (text) */ f.parent().empty().text(vname); /* Re-enable fields and add button */ $( 'button#addvar', container ).prop( 'disabled', false ); $( 'button.md-btn', container ).prop('disabled', false); $( 'textarea.expr', container ).prop( 'disabled', false ); $( 'textarea.expr', row ).focus(); /* Do the regular stuff */ handleVariableChange( null ); } }); $( 'div.varlist', container ).append( editrow ); $( 'div.re-varname input', editrow ).focus(); } /** * Redraw variables and expressions. */ function redrawVariables() { var container = $('div#tab-vars.reactortab div#reactorvars'); container.empty(); var gel = $('
    '); gel.append('
    Defined Variables
    '); var list = $( '
    ' ); gel.append( list ); var myid = api.getCpanelDeviceId(); var cdata = getConfiguration( myid ); var cstate = getConditionStates( myid ); var csvars = cstate.vars || {}; /* Create a list of variables by index, sorted. cdata.variables is a map/hash, not an array */ var vix = []; for ( var vn in ( cdata.variables || {} ) ) { if ( cdata.variables.hasOwnProperty( vn ) ) { var v = cdata.variables[vn]; vix.push( v ); } } vix.sort( function( a, b ) { var i1 = a.index || -1; var i2 = b.index || -1; if ( i1 === i2 ) return 0; return ( i1 < i2 ) ? -1 : 1; }); var lx = vix.length; for ( var ix=0; ix 64 ? val.substring(0,61) + '...' : val; blk.text( 'Last result: ' + abbrev ).attr( 'title', ""===val ? "(empty string)" : val ); } } else { blk.text( '(expression has not yet been evaluated or caused an error)' ).attr( 'title', "" ); } if ( 0 !== vd.export ) { $( 'button.re-export', el ).addClass( 'attn' ); } list.append( el ); } /* Add "Add" button */ $( '
    ' ) .append( '
    ' ) .appendTo( gel ); $( 'button#addvar', gel ).closest( 'div' ).append( getWiki( 'Expressions-&-Variables' ) ); /* Append the group */ container.append( gel ); list.sortable({ vertical: true, containment: 'div.varlist', helper: "clone", handle: ".draghandle", cancel: "", /* so draghandle can be button */ update: handleVariableChange }); $("button#addvar", container).on( 'click.reactor', handleAddVariableClick ); $("button.saveconf", container).on( 'click.reactor', handleSaveClick ); $("button.revertconf", container).on( 'click.reactor', handleRevertClick ); updateVariableControls(); } function doVariables() { console.log("doVariables()"); try { /* Make sure changes are saved. */ var myid = api.getCpanelDeviceId(); checkUnsaved( myid ); if ( ! initModule() ) { return; } header(); /* Our styles. */ if ( 0 === $( 'style#reactor-expression-styles' ).length ) { $('head').append( ''); } /* Body content */ var html = '
    '; html += '

    Expressions/Variables

    '; html += '
    Note that "Last Result" values shown here do not update dynamically. For help with expressions and functions, please see the Reactor Documentation.
    '; html += '
    '; html += '
    '; //.reactortab html += footer(); api.setCpanelContent(html); if ( checkRemoteAccess() ) { $( 'div.reactortab' ).prepend( $( '
    ' ).text( msgRemoteAlert ) ); } redrawVariables(); captureControlPanelClose( $('div.reactortab') ); } catch (e) { console.log( 'Error in ReactorSensor.doVariables(): ' + String( e ) ); console.log(e); alert( e.stack ); } } /** *************************************************************************** * * A C T I V I T I E S * ** **************************************************************************/ function testLua( lua, el, row ) { $.ajax({ url: api.getDataRequestURL(), method: 'POST', /* data could be long */ data: { id: "lr_Reactor", action: "testlua", lua: lua, r: Math.random() }, dataType: 'json', cache: false, timeout: 5000 }).done( function( data, statusText, jqXHR ) { if ( data.status ) { /* Good Lua */ return; } else if ( data.status === false ) { /* specific false, not undefined */ el.addClass( "tberror" ); $( 'div.actiondata' , row ).prepend( '
    ' ); $( 'div.tberrmsg', row ).text( data.message || "Error in Lua" ); } }).fail( function( stat ) { console.log("Failed to check Lua: " + stat); }); } function makeSceneMenu() { var ud = api.getUserData(); var scenes = api.cloneObject( ud.scenes || [] ); var menu = $( '' ); /* If lots of scenes, sort by room; otherwise, use straight as-is */ var i, l; if ( true || scenes.length > 10 ) { var rooms = api.cloneObject( ud.rooms ); var rid = {}; l = rooms.length; for ( i=0; i 0 ) { menu.append( xg ); } xg = $( '' ) .attr( 'label', ( rid[r] || {} ).name || ( "Room " + String(r) ) ); lastRoom = r; } xg.append( $( '' ).val( scenes[i].id ) .text( String(scenes[i].name) + ' (#' + String(scenes[i].id) + ( scenes[i].paused ? ", disabled" : "" ) + ')' ) ); } if ( xg && $( 'option:first', xg ).length > 0 ) { menu.append( xg ); } } else { /* Simple alpha list */ scenes.sort( function(a, b) { return ( a.name || "" ).toLowerCase() < ( b.name || "" ).toLowerCase() ? -1 : 1; } ); l = scenes.length; for ( i=0; i'); opt.text( scenes[i].name || ( "#" + scenes[i].id ) ); menu.append( opt ); } } return menu; } function validateActionRow( row ) { var actionType = $('select.re-actiontype', row).val(); $('.tberror', row).removeClass( 'tberror' ); $('.tbwarn', row).removeClass( 'tbwarn' ); row.removeClass( 'tberror' ); $( 'div.tberrmsg', row ).remove(); var pfx = row.attr( 'id' ) + '-'; var dev, k; switch ( actionType ) { case "comment": break; case "delay": dev = $( 'input#' + idSelector( pfx + 'delay' ), row ); var delay = dev.val() || ""; if ( delay.match( varRefPattern ) ) { // Variable reference. ??? check it? } else if ( delay.match( /^([0-9][0-9]?)(:[0-9][0-9]?){1,2}$/ ) ) { // MM:SS or HH:MM:SS } else { var n = parseInt( delay ); if ( isNaN( n ) || n < 1 ) { dev.addClass( "tberror" ); } } break; case "device": dev = $( 'select.devicemenu', row ).val(); if ( isEmpty( dev ) ) { $( 'select.devicemenu', row ).addClass( 'tberror' ); } else { var devnum = parseInt( dev ); if ( -1 === devnum ) devnum = api.getCpanelDeviceId(); var sact = $('select.re-actionmenu', row).val(); if ( isEmpty( sact ) ) { $( 'select.re-actionmenu', row ).addClass( "tberror" ); } else { // check parameters, with value/type check when available? // type, valueSet/value list, min/max var ai = actions[ sact ]; if ( ai && ai.deviceOverride && ai.deviceOverride[devnum] ) { console.log('validateActionRow: applying device ' + devnum + ' override for ' + sact); ai = ai.deviceOverride[devnum]; } if ( ! ai ) { console.log('validateActionRow: no info for ' + sact + ' for device ' + devnum); return; /* If we don't know, we don't check */ } var lk = ai.parameters ? ai.parameters.length : 0; for ( k=0; k inttypes[typ].max ) || ( undefined !== p.min && v < p.min ) || ( undefined != p.max && v > p.max ) ) { field.addClass( 'tbwarn' ); // ???explain why? } } else if ( typ.match( /(r4|r8|float|number)/i ) ) { /* Float */ v = parseFloat( v ); if ( isNaN( v ) || ( undefined !== p.min && v < p.min ) || ( undefined !== p.max && v > p.max ) ) { field.addClass( 'tbwarn' ); } } else if ( "boolean" === typ ) { if ( ! v.match( /^(0|1|true|false|yes|no)$/i ) ) { field.addClass( 'tbwarn' ); } } else if ( "string" !== typ ) { /* Known unsupported/TBD: date/dateTime/dateTime.tz/time/time.tz (ISO8601), bin.base64, bin.hex, uri, uuid, char, fixed.lll.rrr */ console.log("validateActionRow: no validation for type " + String(typ)); } } } } } } break; case "housemode": break; case "runscene": dev = $( 'select.re-scene', row ); dev.toggleClass( 'tberror', isEmpty( dev.val() ) ); break; case "runlua": var lua = $( 'textarea.re-luacode', row ).val() || ""; // check Lua? if ( lua.match( /^[\r\n\s]*$/ ) ) { $( 'textarea.re-luacode', row ).addClass( "tberror" ); } else { testLua( lua, $( 'textarea.re-luacode', row ), row ); } break; case "rungsa": dev = $( 'select.devicemenu', row ); dev.toggleClass( 'tberror', isEmpty( dev.val() ) ); dev = $( 'select.re-activity', row ); dev.toggleClass( 'tberror', isEmpty( dev.val() ) ); break; case "stopgsa": dev = $( 'select.devicemenu', row ); dev.toggleClass( 'tberror', isEmpty( dev.val() ) ); break; case "setvar": var vname = $( 'select.re-variable', row ); vname.toggleClass( 'tberror', isEmpty( vname.val() ) ); break; case "resetlatch": dev = $( 'select.devicemenu', row ); dev.toggleClass( 'tberror', isEmpty( dev.val() ) ); break; case "notify": var method = $( 'select.re-method', row ).val() || ""; var ninfo = arrayFindValue( notifyMethods, function( v ) { return v.id === method; } ) || notifyMethods[0]; if ( false !== ninfo.users && 0 === $("div.re-users input:checked", row ).length ) { $( 'div.re-users', row ).addClass( 'tberror' ); } /* Message cannot be empty. */ dev = $( 'input.re-message', row ); var vv = (dev.val() || "").trim(); dev.val( vv ); if ( isEmpty( vv ) ) { dev.addClass( 'tberror' ); } else if ( null !== vv.match(/{[^}]+}/) ) { /* Check substitution validity and syntax */ $( 'div.nativesub' ).toggle( "" === method ); $( 'div.subformat' ).toggle( !vv.match( varRefPattern ) ); } var lf = ninfo.extra ? ninfo.extra.length : 0; for ( var f=0; f= 0 ) { console.log("Scene " + scenes[k].id + " already marked for deletion"); } else if ( undefined === valids[String(scenes[k].triggers[0].arguments[0].value)] ) { console.log("Marking orphaned notification scene #" + scenes[k].id); deletes.push(scenes[k].id); } else { /* Save scene on notification. Remove from valids so any dups are also removed. */ cf.notifications[String(scenes[k].triggers[0].arguments[0].value)].scene = scenes[k].id; delete valids[String(scenes[k].triggers[0].arguments[0].value)]; } } } function _rmscene( myid, dl ) { var scene = dl.pop(); if ( scene ) { console.log("Removing unused notification scene #" + scene); $.ajax({ url: api.getDataRequestURL(), data: { id: "scene", action: "delete", scene: scene }, dataType: "text", cache: false, timeout: 5000 }).always( function() { _rmscene( myid, dl ); }); } } _rmscene( myid, deletes ); } /* Rebuild actions for section (class actionlist) */ function buildActionList( root ) { if ( $('.tberror', root ).length > 0 ) { return false; } /* Set up scene framework and first group with no delay */ var id = root.attr( 'id' ); var scene = { isReactorScene: 1, id: id, name: id, groups: [] }; var group = { groupid: "grp0", actions: [] }; scene.groups.push( group ); var firstScene = true; $( 'div.actionrow', root ).each( function( ix ) { var row = $( this ); var pfx = row.attr( 'id' ) + '-'; var actionType = $( 'select.re-actiontype', row ).val(); var action = { type: actionType, index: ix+1 }; var k, pt, t, devnum, devobj; switch ( actionType ) { case "comment": action.comment = $( 'input.argument', row ).val() || ""; break; case "delay": t = $( 'input#' + idSelector( pfx + 'delay' ), row ).val() || "0"; if ( t.match( varRefPattern ) ) { /* Variable reference is OK as is. */ } else { if ( t.indexOf( ':' ) >= 0 ) { pt = t.split( /:/ ); t = 0; for ( var i=0; i 0 ) { group = { actions: [], delay: t, delaytype: $( 'select.re-delaytype', row ).val() || "inline" }; scene.groups.push( group ); } else { /* There are no actions in the current group; just modify the delay in this group. */ group.delay = t; group.delaytype = $( 'select.re-delaytype', row ).val() || "inline"; } /* We've set up a new group, not an action, so take an early exit from this each() */ return true; case "device": action.device = parseInt( $( 'select.devicemenu', row ).val() ); devnum = -1 === action.device ? api.getCpanelDeviceId() : action.device; devobj = api.getDeviceObject( devnum ); action.devicename = (devobj || {}).name; delete action.deviceName; /* remove old form */ t = $( 'select.re-actionmenu', row ).val() || ""; pt = t.split( /\//, 2 ); action.service = pt[0]; action.action = pt[1]; var ai = actions[ t ]; if ( ai && ai.deviceOverride && ai.deviceOverride[devnum] ) { ai = ai.deviceOverride[devnum]; } /* Make LUT of known fields (if we know any) */ var ap = {}; var lk = ( ai && ai.parameters ) ? ai.parameters.length : 0; for ( k=0; k < lk; k++ ) ap[ai.parameters[k].name] = ai.parameters[k]; /* We always use the on-page fields as the reference list of parameters. What the user sees is what we store. */ action.parameters = []; $( '.argument', row ).each( function() { var val = $( this ).val() || ""; var pname = ($( this ).attr( 'id' ) || "unnamed").replace( pfx, '' ); if ( ! isEmpty( val ) || ( ap[pname] && !ap[pname].optional ) ) { action.parameters.push( { name: pname, value: val } ); } delete ap[pname]; }); /* Known fields that remain... */ for ( k in ap ) { if ( ap.hasOwnProperty(k) ) { if ( ap[k].value ) { /* Supply fixed value field */ action.parameters.push( { name: k, value: ap[k].value } ); } else if ( ! ap[k].optional ) { action.parameters.push( { name: k, value: "" } ); } } } delete action.wrap; break; case "housemode": action.housemode = $( 'select.re-mode', row ).val() || "1"; break; case "runscene": action.scene = parseInt( $( "select.re-scene", row ).val() || "0" ); if ( isNaN( action.scene ) || 0 === action.scene ) { console.log("buildActionList: invalid scene selected"); scene = false; return false; } if ( "V" === ($( 'select.re-method', row ).val() || "") ) { action.usevera = 1; } else { delete action.usevera; } // action.sceneName = sceneByNumber[ action.scene ].name $.ajax({ url: api.getDataRequestURL(), data: { id: "lr_Reactor", action: "preloadscene", device: api.getCpanelDeviceId(), scene: action.scene, flush: firstScene ? 0 : 1, r: Math.random() }, dataType: "json", cache: false, timeout: 5000 }).done( function( data, statusText, jqXHR ) { }).fail( function( jqXHR ) { }); firstScene = false; break; case "runlua": var lua = $( 'textarea.re-luacode', row ).val() || ""; lua = lua.replace( /\r\n/g, "\n" ); lua = lua.replace( /\r/, "\n" ); lua = lua.replace( /\s+\n/g, "\n" ); lua = lua.replace( /[\r\n\s]+$/m, "" ); // rtrim lua = unescape( encodeURIComponent( lua ) ); // Fanciness to keep UTF-8 chars well if ( isEmpty( lua ) ) { delete action.encoded_lua; action.lua = ""; } else { action.encoded_lua = 1; action.lua = btoa( lua ); } break; case "rungsa": devnum = parseInt( $( 'select.devicemenu', row ).val() || "-1" ); if ( isNaN( devnum ) || devnum < 0 ) { delete action.device; delete action.devicename; } else { action.device = devnum; devobj = api.getDeviceObject( devnum < 0 ? api.getCpanelDeviceId() : devnum ); action.devicename = devobj.name; } delete action.deviceName; /* remove old form */ action.activity = $( 'select.re-activity', row ).val() || ""; if ( $( 'input.re-stopall', row ).prop( 'checked' ) ) { action.stopall = 1; } else { delete action.stopall; } break; case "stopgsa": devnum = parseInt( $( 'select.devicemenu', row ).val() || "-1" ); if ( isNaN( devnum ) || devnum < 0 ) { delete action.device; delete action.devicename; } else { action.device = devnum; devobj = api.getDeviceObject( devnum < 0 ? api.getCpanelDeviceId() : devnum ); action.devicename = devobj.name; } delete action.deviceName; /* remove old form */ action.activity = $( 'select.re-activity', row ).val() || ""; if ( isEmpty( action.activity ) ) { delete action.activity; } break; case "setvar": action.variable = $( 'select.re-variable', row ).val(); action.value = $( 'input#' + idSelector( pfx + "value" ), row ).val(); if ( $( "input.tbreeval", row ).prop( "checked" ) ) { action.reeval = 1; } else { delete action.reeval; } break; case "resetlatch": devnum = parseInt( $( 'select.devicemenu', row ).val() || "-1" ); if ( devnum < 0 || isNaN( devnum ) ) { delete action.device; delete action.devicename; } else { action.device = devnum; devobj = api.getDeviceObject( devnum < 0 ? api.getCpanelDeviceId() : devnum ); action.devicename = devobj.name; } delete action.deviceName; /* remove old form */ var gid = $( 'select.re-group', row ).val() || ""; if ( isEmpty( gid ) ) { delete action.group; } else { action.group = gid; } break; case "notify": var nid = $( 'input.re-notifyid', row ).val() || ""; var method = $( 'select.re-method', row ).val() || ""; var ua = $( 'div.re-users input:checked', row ); var users = [], unames = []; ua.each( function() { var val = $(this).val(); if ( !isEmpty( val ) ) { users.push( val ); if ( userIx[val] ) unames.push( userIx[val].name ); } }); var myid = api.getCpanelDeviceId(); var cf = getConfiguration( myid ); cf.notifications = cf.notifications || { nextid: 1 }; if ( "" === nid || undefined === cf.notifications[nid] ) { /* No slot assigned or gone missing, reassign: get next id and create slot */ nid = nextNotification( cf ); cf.notifications[nid] = { 'id': parseInt(nid) }; $( 'input.re-notifyid', row ).val( nid ); } cf.notifications[nid].users = users.join(','); cf.notifications[nid].usernames = unames.join(','); cf.notifications[nid].message = $( 'input.re-message', row ).val() || nid; action.notifyid = nid; if ( "" === method ) { delete action.method; checkNotificationScene( myid, nid ); $( 'input.re-message', row ).prop( 'disabled', cf.notifications[nid].veraalerts == 1 ); $( '.vanotice', row ).toggle( cf.notifications[nid].veraalerts == 1 ); } else { action.method = method; delete cf.notifications[nid].veraalerts; $( 'input.re-message', row ).prop( 'disabled', false ); $( '.vanotice', row ).hide(); } var ninfo = arrayFindValue( notifyMethods, function( v ) { return v.id === action.method; } ) || notifyMethods[0]; var lf = ninfo.extra ? ninfo.extra.length : 0; for ( var f=0; freport' ); } else if ( 0 !== $( '.tbwarn' ).length ) { $( '.re-titlewarning' ) .html( 'warning' ); } else { $( '.re-titlewarning' ).html( "" ); } /* Save and revert buttons */ updateSaveControls(); } /** * Given a section (class actionlist), update cdata to match. */ function updateActionList( section ) { var sn = section.attr( 'id' ); if ( !isEmpty( sn ) ) { var scene = buildActionList( section ); if ( scene ) { var cd = getConfiguration(); cd.activities[sn] = scene; configModified = true; } } } function changeActionRow( row ) { configModified = true; row.addClass( "tbmodified" ); $( 'div.actionlist' ).addClass( "tbmodified" ); // all lists, because save saves all. validateActionRow( row ); var section = row.closest( 'div.actionlist' ); updateActionList( section ); updateActionControls(); } function handleActionValueChange( ev ) { var row = $( ev.currentTarget ).closest( 'div.actionrow' ); changeActionRow( row ); } function appendVariables( menu ) { var cd = getConfiguration(); var hasOne = false; var xg = $( '' ); for ( var vname in ( cd.variables || {} ) ) { if ( cd.variables.hasOwnProperty( vname ) ) { hasOne = true; xg.append( $( '' ).val( '{' + vname + '}' ) .text( '{' + vname + '}' ) ); } } if ( hasOne ) { menu.append( xg ); } } function changeNotifyActionMethod( $row, method, action ) { var ninfo = arrayFindValue( notifyMethods, function( v ) { return v.id === method; } ) || notifyMethods[0]; $( "select.re-method", $row ).val( ninfo.id ); /* override */ // $( 'div.re-users', $row ).toggle( false !== ninfo.users ); $( 'div.re-users', $row ).toggleClass( 'tbhidden', false === ninfo.users ) .toggleClass( 'tbinline', false !== ninfo.users ); $( 'div.re-extrafields', $row ).remove(); $( 'div.vanotice', $row ).hide(); $( 'div.notifynotice', $row ).remove(); /* Do not clear message or users (even if we don't use them) */ var f, lf, fld; if ( ninfo.extra ) { var $extra = $( '
    ' ) .appendTo( $( 'div.actiondata', $row ) ); lf = ninfo.extra.length; for ( f=0; f' ); var lv = fld.values ? fld.values.length : 0; for ( var vi=0; vi' ).val( pm[1] ).text( pm[2] ) .appendTo( xf ); } } } else if ( "textarea" === fld.type ) { xf = $( '' ) .attr( 'placeholder', fld.placeholder || "" ); } else { xf = $( '' ) .attr( 'placeholder', fld.placeholder || "" ); if ( Array.isArray( fld.datalist ) ) { // if ( undefined !== window.HTMLDataListElement ) { var dlid = 'notify-' + method + '-' + fld.id; var $dl = $( 'datalist#' + idSelector( dlid ) ); if ( 0 === $dl.length ) { /* build datalist */ var $ct = $( 'div#re-activities' ); $dl = $( '' ).attr( 'id', dlid ).appendTo( $ct ); fld.datalist.forEach( function( v ) { var pm = v.match( "^([^=]*)=(.*)$" ); if ( pm ) { $( '' ).val( pm[1] ).text( pm[2] ) .appendTo( $dl ); } }); } xf.attr( 'list', dlid ); } } if ( ! isEmpty( fld.default ) ) { xf.val( fld.default ); } xf.addClass( 're-extra-' + fld.id ) .on( 'change.reactor', handleActionValueChange ); if ( ! isEmpty( fld.label ) ) { /* Wrap the field in a label */ xf = $( '' ) .text( fld.label ) .toggleClass( "re-fullwidth", fld.fullwidth ) .append( xf ); } xf.appendTo( $extra ); } } var cf = getConfiguration(); if ( action && (cf.notifications || {})[action.notifyid] ) { /* Load current values from passed action */ var note = cf.notifications[action.notifyid]; var scene = findNotificationScene( api.getCpanelDeviceId(), action.notifyid ); var isVA = scene && isVAControlledScene( scene ); if ( isVA && ! note.veraalerts ) { note.veraalerts = 1; configModified = true; } else if ( note.veraalerts && !isVA ) { delete note.veraalerts; configModified = true; } $( 'input.re-message', $row ).val( note.message || "" ); if ( false !== ninfo.users ) { /* See if scene has been updated behind us */ if ( scene && scene.users !== ( note.users || "" ) ) { note.users = scene.users || ""; configModified = true; } var ua = note.users || ""; if ( "" !== ua ) { ua = ua.split( /,/ ); lf = ua.length; for ( f=0; f' ) .text("NOTE: This notification has been modified by VeraAlerts. The message text can only be changed there. You may change recipients here, but you must go into VeraAlerts \"Edit\" mode after so that it updates its data. Delivery and filtering of this message is under control of VeraAlerts.") .toggle( isVA ) .appendTo( $( 'div.actionfooter', $row ) ); $( 'input.re-message', $row ).prop( 'disabled', isVA ); } } if ( ninfo.config ) { var s = getParentState( ninfo.config.name ); if ( isEmpty(s) ) { $( '
    ' ) .text( ninfo.config.warning || ninfo.config.message || "This method requires additional configuration that has not been completed." ) .appendTo( $( 'div.actionfooter', $row ) ); $( 'div.notifynotice', $row ).append( getWiki( 'Notify-Action' ) ); } } if ( ninfo.requiresUnsafeLua && ! unsafeLua ) { $( '
    This notification method requires that "Allow Unsafe Lua" (Users & Account Info > Security) be enabled to operate. It is currently disabled.
    ' ) .appendTo( $( 'div.actionfooter', $row ) ); } } function handleNotifyActionMethodChange( ev ) { var $row = $( ev.currentTarget ).closest( '.actionrow' ); var val = $( ev.currentTarget ).val() || ""; changeNotifyActionMethod( $row, val ); return changeActionRow( $row ); } function changeActionAction( row, newVal ) { // assert( row.hasClass( 'actionrow' ) ); /* If action isn't changing, don't obliterate filled fields (i.e. device changes, same action) */ var prev = row.data( 'prev-action' ) || ""; if ( !isEmpty(prev) && prev == newVal ) return; /* Load em up... */ var j, lj; row.data( 'prev-action', newVal ); /* save for next time */ var pfx = row.attr( 'id' ); var ct = $( 'div.actiondata', row ); $( 'label,.argument', ct ).remove(); if ( isEmpty( newVal ) ) { return; } var action = actions[newVal]; /* Check for device override to service/action */ var devNum = parseInt( $( 'select.devicemenu', ct ).val() || "-1" ); if ( devNum === -1 ) devNum = api.getCpanelDeviceId(); if ( !isNaN(devNum) && action && action.deviceOverride && action.deviceOverride[devNum] ) { console.log("changeActionAction: using device override for " + String(devNum)); action = action.deviceOverride[devNum]; if ( undefined != action && undefined == action.name ) { /* exceptions use different key ??? should fix this in data! */ action.name = action.action; } } if ( undefined !== action ) { /* Info assist from our enhancement data */ var lk = action.parameters ? action.parameters.length : 0; for ( var k=0; k