/** * Remote Builder SmartGrid * Version: See ChangeLog * Download: See importUrl in definition * Description: Used in conjunction with child apps to generate tabular reports on device data and publishes them to a dashboard. * * Copyright 2025 Gary J. Milne * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License * for the specific language governing permissions and limitations under the License. * License: * You are free to use this software in an un-modified form. Software cannot be modified or redistributed. * You may use the code for educational purposes or for use within other applications as long as they are unrelated to the * production of tabular data in HTML form, unless you have the prior consent of the author. * You are granted a license to use Remote Builder in its standard configuration without limits. * Use of Remote Builder Advanced (which includes SmartGrid) requires a license key that must be issued to you by the original developer. TileBuilderApp@gmail.com * * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the * implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. * Authors Notes: * For more information on Remote Builder check out these resources. * Original posting on Hubitat Community forum: https://community.hubitat.com/t/release-remote-builder-a-new-way-to-control-devices-7-remotes-available/142060 * Remote Builder Documentation: https://github.com/GaryMilne/Hubitat-RemoteBuilder/blob/main/Remote%20Builder%20Help.pdf * Remote Builder - SmartGrid Documentation: https://github.com/GaryMilne/Hubitat-RemoteBuilder/blob/main/Remote_Builder_SmartGrid_Help.pdf * Remote Builder - SmartGrid - Hard launch announcement https://community.hubitat.com/t/release-smartgrid-create-grids-of-controls-that-you-can-access-from-anywhere/148457 * * Remote Builder - SmartGrid - ChangeLog * Version 1.3.1 - Initial Public Release * Version 1.3.2 - Improved sorting for State and Name columns to use a primary and secondary keys. Added sorting for the Info1 & Info2 columns. * Version 2.0.0 - Re-architected for polling operations. Added highlight to table when pollingUpdate received. * Version 2.0.X - Split variables between state and local storage. Added session tracking for each browser. Add local tracking of checkbox state. Changed appearance of shuttle. Added support for Valves and Locks. Added Icon column. * Version 2.1.X - Converted lastOpen\lastClosed lastLocked\lastUnlocked and lastOn\lastOff to all use lastActive\lastInactive * Version 2.2.X - Removed compiled versions of Cloud and Local content when the Endpoint is disabled * Version 2.3.X - Code cleanup. Adds Network type as an info column. Add Fan, Garage amd Shade control. * Version 2.4.X - Fixed Color and CT highlights. Fixed various minor issues.Added Stretch option to the Horizontal Alignment and made it the default. * Version 2.5.0 - Improved slider controls and added Mozilla support (untested) and suppressed scrollbars for use in Hubitat Dashboards * Version 2.6.X - Added support for AudioVolume. Added 3rd Info column. Added scale for volume slider. * Version 2.7.X - Split Shades and Blinds and added support for Tilt control. Improved appearance. * Version 2.8.0 - Moved Icon logic to Groovy. Added Mozilla support - untested. * Version 3.0.0 - Public release * Version 3.0.1 - Fix issue with old code being used for the body,html and @media sections affecting rotation. Fix bug with hiding Control C. Fan buttons resize better. Added width option for Control columns. * Version 3.0.2 - Improved screen sizing for mobile devices. Added scrollbars when neccessary. Fixed sorting of State column and added directional indicators. * Version 3.0.3 - Reduced the amount of data in the packets sent from JS to the Hub. Lookup of device name from the ID is now performed on the Hub. * Version 3.0.4 - Improved handling of icons and classes. These are determined on the Hub and sent to the client. * Version 3.0.5 - Added support for contacts, leak and temperature along with filtering. Only open contacts or only wet sensors. * Version 3.0.6 - Added ability to pin controls. * Version 3.0.7 - Added options and controls for selecting the selectedRow and pinnedRow colors. Combined the Info and Columns\Headers sections. * Version 3.0.8 - Re-worked logic for the gathering of information for info columns to eliminate unnecessary calls. * Version 3.0.9 - Added missing variables to updateVariables(). Added mid points for tilePreviewWidth. Made all pinned objects remain visible regardless of any filter setting. * Version 3.1.0 - Split Devices into Controls and Sensors. Added halfTone for sort column header box. Flipped definition of Lock to open == active. * Version 3.1.1 - Added a StorageKey() and AppID to isolate any local or session storage variables between iFrames. * Version 3.1.2 - Fixed issue with sorting using State. Sort for state now always use A-Z for the name column as the secondary key. Changed clicked column header to eliminate directional arrows and add a background gradient to indicate direction. Eliminated halftone code. * Version 3.1.3 - Switched sort header directional indicators to a user configurable color underline. * Version 3.1.4 - Bug with highlight color not being initialized correctly. Added some smaller options to control sizes. Added option to select 0 or 1 decimal places for temperatures. Fixed bug with display of Slider values on A/B switch. Changed Help File link. * Version 3.1.5 - Set slider value text color to match row text color. Fixed issue with control columns not being hidden. Added colorTemperature as a possible output for the Info columns. Various minor fixes to issue report in the community forums. * Version 3.1.5B - Fixes issue with one of the the control columns not hiding correctly. Forces a decimal point even for integer values when selecting "1 Decimal Place" to provide more consisten formatting. * Version 3.1.6 - Fixes issue with sensor data in the State column not being formatted as per the general text color and size. Changes how column space is allocated and eliminates the need for <colgroup> * * Gary Milne - January 14th, 2025 @ 9:25 AM V 3.1.6 * **/ /* ToDo's before release None */ /* Known Issues None */ /* Ideas for future releases Add support for Thermostats Add pause-play for polling and\or variable speed polling. Add Scene Selector Control Add Media Control Add alternate\additional polling indicator Look into EventSocket\WebSocket Add Link options */ import groovy.json.JsonSlurper import groovy.json.JsonOutput import groovy.transform.Field import java.text.SimpleDateFormat import java.util.Date import java.time.LocalDate import java.time.LocalDateTime import java.util.TimeZone //These are the data for the pickers used on the child forms. static def textScale() { return ['50', '55', '60', '65', '70', '75', '80', '85', '90', '95', '100', '105', '110', '115', '120', '125', '130', '135', '140', '145', '150', '175', '200', '250', '300', '350', '400', '450', '500'] } static def columnWidth() { return ['50', '60', '70', '80', '90', '100', '110', '120', '130', '140', '150', '160', '170', '180', '190', '200', '210', '220', '230', '240', '250', '260', '270', '280', '290', '300', '350', '400', '450', '500'] } static def textAlignment() { return ['Left', 'Center', 'Right', 'Justify'] } static def opacity() { return ['1', '0.9', '0.8', '0.7', '0.6', '0.5', '0.4', '0.3', '0.2', '0.1', '0'] } static def elementSize() { return ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10'] } static def unitsMap() { return ['°F', ' °F', '°C', ' °C']} static def dateFormatsMap() { return [1: "To: yyyy-MM-dd HH:mm:ss.SSS", 2: "To: HH:mm", 3: "To: h:mm a", 4: "To: HH:mm:ss", 5: "To: h:mm:ss a", 6: "To: E HH:mm", 7: "To: E h:mm a", 8: "To: EEEE HH:mm", 9: "To: EEEE h:mm a", \ 10: "To: MM-dd HH:mm", 11: "To: MM-dd h:mm a", 12: "To: MMMM dd HH:mm", 13: "To: MMMM dd h:mm a", 14: "To: yyyy-MM-dd HH:mm", 15: "To: dd-MM-yyyy h:mm a", 16: "To: MM-dd-yyyy h:mm a", 17: "To: E @ h:mm a" ] } static def dateFormatsList() { return dateFormatsMap().values() } static def createDeviceTypeMap() { def typeMap = [ 1: "Switch", 2: "Dimmer", 3: "RGB", 4: "CT", 5: "RGBW", 10: "Valve", 11:"Lock", 12: "Fan", 13: "Garage Door", 14: "Shade", 15: "Blind", 16: "Volume", 31: "Contact", 32:"Temperature", 33:"Leak" ] // Create the inverse map for name-to-number lookups def nameToNumberMap = typeMap.collectEntries { key, value -> [value, key] } return [typeMap: typeMap, nameToNumberMap: nameToNumberMap] } static def durationFormatsMap() { return [21: "To: Elapsed Time (dd):hh:mm:ss", 22: "To: Elapsed Time (dd):hh:mm"] } static def durationFormatsList() { return durationFormatsMap().values() } static def invalidAttributeStrings() { return ["N/A", "n/a", " ", "-", "--", "?", "??"] } static def devicePropertiesList() { return ["lastActive", "lastInactive", "lastActiveDuration", "lastInactiveDuration", "roomName", "colorName", "colorMode", "power", "healthStatus", "energy", "ID", "network", "deviceTypeName", "lastSeen", "lastSeenElapsed", "battery", "temperature", "colorTemperature"].sort() } static def decimalPlaces() {return ["0 Decimal Places", "1 Decimal Place"]} @Field static final codeDescription = "<b>Remote Builder - SmartGrid 3.1.6 (1/14/25)</b>" @Field static final codeVersion = 316 @Field static final moduleName = "SmartGrid" definition( name: "Remote Builder - SmartGrid", description: "Generates a Grid of Objects that can be fully controlled. It can be executed from a web browser or embedded into a Hubitat Dashboard.", importUrl: "https://raw.githubusercontent.com/GaryMilne/Hubitat-RemoteBuilder/main/Remote_Builder_SmartGrid.groovy", namespace: "garyjmilne", author: "Gary J. Milne", category: "Utilities", iconUrl: "", iconX2Url: "", iconX3Url: "", singleThreaded: false, parent: "garyjmilne:Remote Builder", installOnOpen: true, oauth: true ) //Tells the App how to direct inbound and outbound requests. mappings { path("/tb") {action: [POST: "response", GET: "showApplet"] } path("/tb/data") { action: [POST: "toHub", GET: "fromHub"] } path("/tb/poll") { action: [POST: "", GET: "poll"] } } preferences { page name: "mainPage" } def mainPage(){ if (state.variablesVersion == null || state.variablesVersion < codeVersion) updateVariables() checkNulls() //Basic initialization for the initial release. If it is already initialized then compile the remote on each reload. if (state.initialized == null) initialize() else compile() dynamicPage(name: "mainPage", title: "<div style='text-align:center;color: #c61010; font-size:30px;text-shadow: 0 0 5px #FFF, 0 0 10px #FFF, 0 0 15px #FFF, 0 0 20px #49ff18, 0 0 30px #49FF18, 0 0 40px #49FF18, 0 0 55px #49FF18, 0 0 75px #ffffff;;'> Remote Builder - " + moduleName + " 💡 </div>", uninstall: true, install: true, singleThreaded:false) { section(hideable: true, hidden: state.hidden.Controls, title: buttonLink('btnHideControls', getSectionTitle("Controls"), 20)) { // Input for selecting filter criteria input(name: "filter", type: "enum", title: bold("Filter Controls (optional)"), options: ["All Selectable Controls", "Power Meters", "Switches", "Color Temperature Devices", "Color Devices", "Dimmable Devices", "Valves", "Fans", "Locks", "Garage Doors", "Shades & Blinds"].sort(), required: false, defaultValue: "All Selectable Controls", submitOnChange: true, width: 2, style:"margin-right: 20px") input "myPinnedControls", "enum", title: "<b>Pin These Controls</b>", options: getPinnedItems(myDevices).sort(), multiple: true, submitOnChange: true, width: 2, required: false, newLine: false, style:"margin-right: 20px" // Apply switch-case logic based on the filter value switch (filter) { case "All Selectable Controls": input "myDevices", "capability.powerMeter, capability.switch, capability.valve, capability.lock, capability.garageDoorControl, capability.doorControl, capability.fanControl, capability.audioVolume, capability.windowShade, capability.windowBlind", title: "<b>Select Controls</b>", multiple: true, submitOnChange: true, width: 2, style:"margin-right:25px", newLine:true break case "Power Meters": input "myDevices", "capability.powerMeter", title: "<b>Select Power Meter Devices</b>", multiple: true, submitOnChange: true, newLine: true, width:2, style:"margin-right:25px" break case "Switches": input "myDevices", "capability.switch", title: "<b>Select Switch Devices</b>", multiple: true, submitOnChange: true, newLine : true, width:2, style:"margin-right:25px" break case "Color Temperature Devices": input "myDevices", "capability.colorTemperature", title: "<b>Select Color Temperature Devices</b>", multiple: true, submitOnChange: true, newLine : true, width:2, style:"margin-right:25px" break case "Color Devices": input "myDevices", "capability.colorControl", title: "<b>Select Color Devices</b>", multiple: true, submitOnChange: true, newLine : true, width:2, style:"margin-right:25px" break case "Dimmable Devices": input "myDevices", "capability.switchLevel", title: "<b>Select Dimmable Devices</b>", multiple: true, submitOnChange: true, newLine : true, width:2, style:"margin-right:25px" break case "Valves": input "myDevices", "capability.valve", title: "<b>Select Valves</b>", multiple: true, submitOnChange: true, newLine : true, width:2, style:"margin-right:25px" break case "Fans": input "myDevices", "capability.fanControl", title: "<b>Select Fans</b>", multiple: true, submitOnChange: true, newLine : true, width:2, style:"margin-right:25px" break case "Garage Doors": input "myDevices", "capability.garageDoorControl", title: "<b>Select Garage Doors</b>", multiple: true, submitOnChange: true, newLine : true, width:2, style:"margin-right:25px" break case "Locks": input "myDevices", "capability.lock", title: "<b>Select Locks</b>", multiple: true, submitOnChange: true, newLine : true, width:2, style:"margin-right:25px" break case "Shades & Blinds": input "myDevices", "capability.windowShade, capability.windowBlind", title: "<b>Select Shades & Blinds</b>", multiple: true, submitOnChange: true, newLine : true, width:2, style:"margin-right:25px" break default: if (isLogDebug) log.debug "No filter option selected." } //Allow users to rename devices that fit certain patterns input (name: "isShowDeviceNameModification", type: "enum", title: "<b>Show Device Name Modification</b>", options: ["True", "False"], required: false, multiple: false, defaultValue: "False", submitOnChange: true, width: 2, newLine: true) if (isShowDeviceNameModification == "True") { paragraph("<hr>") input (name: "mySearchText1", title: "<b>Search Device Text #1</b>", type: "string", submitOnChange:true, width:2, defaultValue: "?", newLine:true) input (name: "myReplaceText1", title: "<b>Replace Device Text #1</b>", type: "string", submitOnChange:true, width:2, defaultValue: "") input (name: "mySearchText2", title: "<b>Search Device Text #2</b>", type: "string", submitOnChange:true, width:2, defaultValue: "?", newLine:true) input (name: "myReplaceText2", title: "<b>Replace Device Text #2</b>", type: "string", submitOnChange:true, width:2, defaultValue: "") input (name: "mySearchText3", title: "<b>Search Device Text #3</b>", type: "string", submitOnChange:true, width:2, defaultValue: "?", newLine:true) input (name: "myReplaceText3", title: "<b>Replace Device Text #3</b>", type: "string", submitOnChange:true, width:2, defaultValue: "") input (name: "mySearchText4", title: "<b>Search Device Text #4</b>", type: "string", submitOnChange:true, width:2, defaultValue: "?", newLine:true) input (name: "myReplaceText4", title: "<b>Replace Device Text #4</b>", type: "string", submitOnChange:true, width:2, defaultValue: "") input (name: "mySearchText5", title: "<b>Search Device Text #5</b>", type: "string", submitOnChange:true, width:2, defaultValue: "?", newLine:true) input (name: "myReplaceText5", title: "<b>Replace Device Text #5</b>", type: "string", submitOnChange:true, width:2, defaultValue: "") } paragraph line(1) myText = "<b>Important: If you change the selected devices you must do a " + red("Publish and Subscribe") + " for SmartGrid to work correctly.</b><br>" paragraph myText + "<b>Note:</b> Pinned items always remain at the top of the table sorted A-Z regardless of any other state or sort order applied.<br>" } //Start of Sensors Section section(hideable: true, hidden: state.hidden.Sensors, title: buttonLink('btnHideSensors', getSectionTitle("Sensors"), 20)) { input "myContacts", "capability.contactSensor", title: "<b>Select Contact Sensors</b>", multiple: true, submitOnChange: true, width: 2, style:"margin-right: 20px" input "myPinnedContacts", "enum", title: "<b>Pin These Contact Sensors</b>", options: getPinnedItems(myContacts).sort(), multiple: true, submitOnChange: true, width: 2, required: false input(name: "onlyOpenContacts", type: "enum", title: bold("Unpinned: Only Report Open Contacts"), options: ["True", "False"], required: false, defaultValue: "False", submitOnChange: true, width: 2, style:"margin-right:25px") input "myTemps", "capability.temperatureMeasurement", title: "<b>Select Temp Sensors</b>", multiple: true, submitOnChange: true, width: 2, newLine: true, style:"margin-right: 20px" input ("myPinnedTemps", "enum", title: "<b>Pin These Temp Sensors</b>", options: getPinnedItems(myTemps).sort(), multiple: true, submitOnChange: true, width: 2, required: false, newLine: false) input(name: "onlyReportOutsideRange", type: "enum", title: bold("Unpinned: Only Report Outside Range"), options: ["True", "False"], required: false, defaultValue: "False", submitOnChange: true, width: 2, style:"margin-right:25px") input (name: "minTemp", title: "<b>Lower Threshold</b>", type: "string", submitOnChange:true, width:2, defaultValue: "50", newLine:false) input (name: "maxTemp", title: "<b>Upper Threshold</b>", type: "string", submitOnChange:true, width:2, defaultValue: "90", newLine:false) input "myLeaks", "capability.waterSensor", title: "<b>Select Water Sensors</b>", multiple: true, submitOnChange: true, width: 2, newLine: true, style:"margin-right: 20px" input "myPinnedLeaks", "enum", title: "<b>Pin These Water Sensors</b>", options: getPinnedItems(myLeaks).sort(), multiple: true, submitOnChange: true, width: 2, required: false, newLine: false input(name: "onlyWetSensors", type: "enum", title: bold("Unpinned: Only Report Wet Sensors"), options: ["True", "False"], required: false, defaultValue: "False", submitOnChange: true, width: 2, style:"margin-right:25px") } //Start of Endpoints Section section(hideable: true, hidden: state.hidden.Endpoints, title: buttonLink('btnHideEndpoints', getSectionTitle("Endpoints"), 20)) { input(name: "localEndpointState", type: "enum", title: bold("Local Endpoint State"), options: ["Disabled", "Enabled"], required: false, defaultValue: "Enabled", submitOnChange: true, width: 2, style:"margin-right: 20px") input(name: "cloudEndpointState", type: "enum", title: bold("Cloud Endpoint State"), options: ["Disabled", "Enabled"], required: false, defaultValue: "Disabled", submitOnChange: true, width: 2, style:"margin-right: 20px") paragraph line (1) //Display the Endpoints with links or ask for compilation paragraph "<a href='${state.localEndpoint}' target=_blank><b>Local Endpoint</b></a>: ${state.localEndpoint} " paragraph "<a href='${state.cloudEndpoint}' target=_blank><b>Cloud Endpoint</b></a>: ${state.cloudEndpoint} " myText = "<b>Important: If these endpoints are not generated you may have to enable OAuth in the child application code for this application to work.</b><br>" myText += "Both endpoints can be active at the same time and can be enabled or disable through this interface.<br>" myText += "Endpoints are paused if this instance of the <b>Remote Builder</b> application is paused. Endpoints are deleted if this instance of <b>Remote Builder</b> is removed.<br>" paragraph summary("Endpoint Help", myText) paragraph line (2) } //Start of Polling Section section(hideable: true, hidden: state.hidden.Polling, title: buttonLink('btnHidePolling', getSectionTitle("Polling"), 20)) { input(name: "isPollingEnabled", type: "enum", title: bold("Endpoint Polling"), options: ["Enabled", "Disabled"], required: false, defaultValue: "Disabled", submitOnChange: true, width: 2, style:"margin-right: 20px") myText = "<b>A)</b> You want a more graceful refresh operation on a Hubitat Dashboard. Enable Polling and set the Event Timeout (Publishing Section) to <b>Never</b>. Doing so results in the SmartGrid updating vs doing a complete refresh.<br>" myText += "<b>B)</b> You want an automatic refresh operation for a SmartGrid that is being displayed directly on a device without using a Hubitat Dashboard. In this case you should also enable Polling and set the Event Timeout to <b>Never</b>. This allows you to have a SmartGrid run directly on your phone, tablet or computer and it will automatically update whenever changes are detected.<br>" paragraph summary(red("<b>Important: When to Enable Polling</b><br>"), myText) if (isPollingEnabled == "Enabled"){ input (name: "pollInterval", type: "enum", title: bold('Poll Interval (seconds)'), options: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '15', '20', '30', '60'], required: false, defaultValue: '5', width: 2, submitOnChange: true, newLine:true) input (name: "pollUpdateColorSuccess", type: "color", title: bold2("Update Color Success", pollUpdateColorSuccess), required: false, defaultValue: "#00FF00", submitOnChange: true, width:2) input (name: "pollUpdateColorFail", type: "color", title: bold2("Update Color Fail", pollUpdateColorFail), required: false, defaultValue: "#FF0000", submitOnChange: true, width:2) input (name: "pollUpdateWidth", type: "enum", title: bold('Update Width'), options: elementSize(), required: false, defaultValue: '5', width: 2, submitOnChange: true) input (name: "pollUpdateDuration", type: "enum", title: bold('Update Duration'), options: elementSize(), required: false, defaultValue: '2', width: 2, submitOnChange: true) input (name: "shuttleHeight", type: "enum", title: bold('Refresh Bar Height'), options: elementSize(), required: false, defaultValue: '2', width: 2, submitOnChange: true, newLine:true) input (name: "shuttleColor", type: "color", title: bold2("Refresh Bar Color", shuttleColor), required: false, defaultValue: "#000000", submitOnChange: true, width:2) input (name: "commandTimeout", type: "enum", title: bold('Command Timeout (seconds)'), options: ['5', '6', '7', '8', '9', '10', '12', '15', '20'], required: false, defaultValue: '10', width: 2, submitOnChange: true, newLine:true) } myText = "You can configure the SmartGrid to poll the endpoint and apply any changes that are found. If there are no changes the SmartGrid goes back to sleep until the next poll interval.<br>" myText += "<b>Poll Interval:</b> The frequency at which the Hub will be contacted to ask if there are any updates available.<br>" myText += "<b>Poll Update Success Color:</b> When updates are applied the Grid will be outlined in the selected color.<br>" myText += "<b>Poll Update Failure Color:</b> When updates are requested but no changes are received within the command timeout period the Grid will be outlined in the selected color.<br>" myText += "<b>Poll Update Width:</b> The width of the outline in pixels when updates are applied.<br>" myText += "<b>Poll Update Duration:</b> The duration in seconds that the Success\\Failure outline is displayed.<br><br>" myText += "<b>Refresh Bar:</b> The Refresh Bar is displayed beneath the SmartGrid and is a visual indicator of the polling process. When the bar hits either edge then a polling event will occur and any changes will be picked up.<br>" myText += "<b>Refresh Bar Height:</b> The height of the bar beneath the SmartGrid that identifies the position in the polling cycle.<br>" myText += "<b>Refresh Bar Color:</b> The color of the bar beneath the SmartGrid that identifies the position in the polling cycle.<br>" myText += "<b>Command Timeout:</b> The amount of time allowed to pass without a response from the Hub before a request is deemed to have failed.<br>" myText += "When the polling process discovers an update is pending then the SmartGrid is refreshed and the table is outlined for 5 seconds using the Poll Update properties configured above.<br>" myText += "<b>Note: </b> You can initiate a full refresh of the table at anytime regardless of the polling interval using the Refresh Icon <b>↻</b>." paragraph summary("Polling Help", myText) paragraph line (2) } //Start of Design Section section(hideable: true, hidden: state.hidden.Design, title: buttonLink('btnHideDesign', getSectionTitle("Design"), 20)) { input(name: "displayEndpoint", type: "enum", title: bold("Endpoint to Display"), options: ["Local", "Cloud"], required: false, defaultValue: "Local", submitOnChange: true, width: 2, style:"margin-right:25px") input(name: "tilePreviewWidth", type: "enum", title: bold("Max Width (x200px)"), options: [1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5, 6, 7, 8], required: false, defaultValue: 2, submitOnChange: true, style: "width:12%;margin-right:25px") input(name: "tilePreviewHeight", type: "enum", title: bold("Preview Height (x190px)"), options: [1, 2, 3, 4, 5, 6, 7, 8], required: false, defaultValue: 2, submitOnChange: true, style: "width:12%;margin-right:25px") input(name: "tilePreviewBackground", type: "color", title: bold2("Preview Background Color", tb), required: false, defaultValue: "#000000", width: 3, submitOnChange: true, style: "margin-right:25px") if (myRemoteName != null && myRemote != null) input(name: "publishSubscribe", type: "button", title: "Publish and Subscribe", backgroundColor: "#27ae61", textColor: "white", submitOnChange: true, width: 2, style:"margin-top:20px;margin-right:25px") //Add extra space for the padding. def maxWidth = (tilePreviewWidth.toFloat() * 200) + 50 def maxHeight = (tilePreviewHeight.toFloat() * 190) + 50 if ( displayEndpoint == "Local" ) paragraph "<iframe src='${state.localEndpoint}' width='${maxWidth}' height='${maxHeight}' style='border:solid; margin-left:50px; padding:25px; background-color:${tilePreviewBackground};' scrolling='no' ></iframe>" if ( displayEndpoint == "Cloud" ) paragraph "<iframe src='${state.cloudEndpoint}' width='${maxWidth}' height='${maxHeight}' style='border:solid; margin-left:50px; padding:25px; background-color:${tilePreviewBackground};' scrolling='no'></iframe>" paragraph line(1) input (name: "customizeSection", type: "enum", title: bold("Customize"), required: false, options: ["General", "Title", "Headers & Columns", "Rows", "Experimental" ], defaultValue: "Table", submitOnChange: true, width: 2, newLineAfter:true) if (settings.customizeSection == "General") { input(name: "defaultDateTimeFormat", title: bold("Date Time Format"), type: "enum", options: dateFormatsMap(), submitOnChange: true, defaultValue: 3, width: 2, style:"margin-right:25px") input(name: "defaultDurationFormat", title: bold("Duration Format"), type: "enum", options: durationFormatsMap(), submitOnChange: true, defaultValue: 21, width: 2, style:"margin-right:25px") input(name: "controlSize", title: bold("Control Size"), type: "enum", options: ["7.5", "10", "12.5", "15", "17.5", "20", "22.5", "25", "27.5", "30"], submitOnChange: true, defaultValue: "17.5", width: 2, style:"margin-right:25px") input (name: "ha", type: "enum", title: bold("Horizontal Alignment"), required: false, options: ["Stretch", "Left", "Center", "Right" ], defaultValue: "Stretch", submitOnChange: true, width: 2, style:"margin-right:25px", newLine: true) input (name: "thp", type: "enum", title: bold("Horizontal Padding"), options: elementSize(), required: false, defaultValue: 3, submitOnChange: true, width: 2, style:"margin-right:25px" ) input (name: "tvp", type: "enum", title: bold("Vertical Padding"), options: elementSize(), required: false, defaultValue: "3", submitOnChange: true, width: 2, style:"margin-right:25px" ) input(name: "invalidAttribute", title: bold("Invalid Attribute String"), type: "enum", options: invalidAttributeStrings(), submitOnChange: true, defaultValue: "N/A", width: 2, style:"margin-right:25px", newLine:true) input ("tempUnits", "enum", title: "<b>Temperature Units</b>", options: unitsMap(), multiple: false, submitOnChange: true, width: 2, required: false, style:"margin-right:25px") input ("tempDecimalPlaces", "enum", title: "<b>Temperature Decimal Places</b>", options: ["0 Decimal Places", "1 Decimal Place"], multiple: false, submitOnChange: true, width: 2, required: false) input(name: "sortHeaderHintAZ", type: "color", title: bold("Sort Header Hint A-Z"), required: false, defaultValue: "#00FF00", submitOnChange: true, width: 2, style:"margin-right:25px", newLine: true ) input(name: "sortHeaderHintZA", type: "color", title: bold("Sort Header Hint Z-A"), required: false, defaultValue: "#FF0000", submitOnChange: true, width: 2, style:"margin-right:25px" ) } if (settings.customizeSection == "Title") { input(name: "tt", type: "string", title: bold("Title Text (? to disable)"), required: false, defaultValue: "?", submitOnChange: true, width: 2, style:"margin-right:25px" ) input(name: "ts", type: "enum", title: bold("Text Size %"), options: textScale(), required: false, defaultValue: "150", submitOnChange: true, width: 2, style:"margin-right:25px" ) input(name: "tp", type: "enum", title: bold("Text Padding"), options: elementSize(), required: false, defaultValue: "5", submitOnChange: true, width: 2, style:"margin-right:25px" ) input(name: "ta", type: "enum", title: bold("Text Alignment"), options: textAlignment(), required: false, defaultValue: "Center", submitOnChange: true, width: 2, style:"margin-right:25px" ) input(name: "tc", type: "color", title: bold2("Text Color", tc), required: false, defaultValue: "#000000", submitOnChange: true, width: 2, newLine:true, style:"margin-right:25px" ) input(name: "tb", type: "color", title: bold2("Background Color", tb), required: false, defaultValue: "#000000", submitOnChange: true, width: 2, style:"margin-right:25px" ) input(name: "to", type: "enum", title: bold("Background Opacity"), options: opacity(), required: false, defaultValue: "1", submitOnChange: true, width:2, style:"margin-right:25px" ) } if (settings.customizeSection == "Headers & Columns") { input(name: "hts", type: "enum", title: bold("Text Size %"), options: textScale(), required: false, defaultValue: "125", width: 2, submitOnChange: true, style:"margin-right:30px; margin-left:10px") input(name: "htc", type: "color", title: bold2("Text Color", htc), required: false, defaultValue: "#000000", width: 2, submitOnChange: true, style:"margin-right:30px") input(name: "hbc", type: "color", title: bold2("Background Color", hbc), required: false, defaultValue: "#90C226", width: 2, submitOnChange: true, style:"margin-right:30px") input(name: "hbo", type: "enum", title: bold("Bg Opacity"), options: opacity(), required: false, defaultValue: "1", width:2, submitOnChange: true, style:"margin-right:30px") input(name: "hideColumn1", type: "bool", title: bold("Hide Column 1 - Selection Boxes?"), required: false, defaultValue: false, width:3, submitOnChange: true, style:"margin-top:40px; margin-left:10px", newLine:true) input(name: "hideColumn2", type: "bool", title: bold("Hide Column 2 - Icons?"), required: false, defaultValue: false, width:2, submitOnChange: true, style:"margin-top:40px;", newLine:false) input(name: "column3Header", type: "string", title: bold("Column 3 Header"), required: false, defaultValue: "Name", width: 2, submitOnChange: true, style:"margin-top:20px;", newLine:false) input(name: "hideColumn4", type: "bool", title: bold("Hide Column 4 - State?"), required: false, defaultValue: false, width:3, submitOnChange: true, style:"margin-top:40px;margin-right:30px; margin-left:10px", newLine:false) input(name: "hideColumn5", type: "bool", title: bold("Hide Column 5 - Control A/B - Level/°K?"), required: false, defaultValue: false, width:3, submitOnChange: true, style:"margin-top:40px", newLine:true) if (hideColumn5 == false) { input(name: "column5Header", type: "string", title: bold("Column 5 Header"), required: false, defaultValue: "Control A/B", width: 2, submitOnChange: true) input(name: "column5Width", type: "enum", title: bold("Column 5 Width (px)"), options: columnWidth(), required: false, defaultValue: 100, submitOnChange: true, width: 2, style:"margin-right:25px") } input(name: "hideColumn6", type: "bool", title: bold("Hide Column 6 - Control C - Color?"), required: false, defaultValue: false, width:3, submitOnChange: true, style:"margin-top:40px", newLine:true) if (hideColumn6 == false) { input(name: "column6Header", type: "string", title: bold("Column 6 Header"), required: false, defaultValue: "Control C", width: 2, submitOnChange: true) input(name: "column6Width", type: "enum", title: bold("Column 6 Width (px)"), options: columnWidth(), required: false, defaultValue: 100, submitOnChange: true, width: 2, style:"margin-right:25px") } input(name: "hideColumn7", type: "bool", title: bold("Hide Column 7 - Info 1"), required: false, defaultValue: false, width:3, submitOnChange: true, style:"margin-top:40px", newLine:true) if (hideColumn7 == false) { input(name: "column7Header", type: "string", title: bold("Info 1 Header Text"), required: false, width: 2, submitOnChange: true) input(name: "its1", type: "enum", title: bold("Size %"), options: textScale(), required: false, defaultValue: "80", width: 2, submitOnChange: true) input(name: "ita1", type: "enum", title: bold("Alignment"), options: textAlignment(), required: false, defaultValue: "Center", width: 2, submitOnChange: true) input(name: "info1Source", type: "enum", title: bold("Data Source"), required: false, multiple: false, defaultValue: "lastActive", options: devicePropertiesList(), submitOnChange: true, width: 2) } input(name: "hideColumn8", type: "bool", title: bold("Hide Column 8 - Info 2"), required: false, defaultValue: false, width:3, submitOnChange: true, style:"margin-top:40px", newLine:true) if (hideColumn8 == false) { input(name: "column8Header", type: "string", title: bold("Info 2 Header Text"), required: false, width: 2, submitOnChange: true) input(name: "its2", type: "enum", title: bold("Size %"), options: textScale(), required: false, defaultValue: "80", width: 2, submitOnChange: true) input(name: "ita2", type: "enum", title: bold("Alignment"), options: textAlignment(), required: false, defaultValue: "Center", width: 2, submitOnChange: true) input(name: "info2Source", type: "enum", title: bold("Data Source"), required: false, multiple: false, defaultValue: "lastActiveDuration", options: devicePropertiesList(), submitOnChange: true, width: 2) } input(name: "hideColumn9", type: "bool", title: bold("Hide Column 9 - Info 3"), required: false, defaultValue: false, width:3, submitOnChange: true, style:"margin-top:40px", newLine:true) if (hideColumn9 == false) { input(name: "column9Header", type: "string", title: bold("Info 3 Header Text"), required: false, width: 2, submitOnChange: true) input(name: "its3", type: "enum", title: bold("Size %"), options: textScale(), required: false, defaultValue: "80", width: 2, submitOnChange: true) input(name: "ita3", type: "enum", title: bold("Alignment"), options: textAlignment(), required: false, defaultValue: "Center", width: 2, submitOnChange: true) input(name: "info3Source", type: "enum", title: bold("Data Source"), required: false, multiple: false, defaultValue: "roomName", options: devicePropertiesList(), submitOnChange: true, width: 2) } input(name: "hideColumn10", type: "bool", title: bold("Hide Column 10 - Pin"), required: false, defaultValue: false, width:2, submitOnChange: true, style:"margin-top:40px", newLine:true) } if (settings.customizeSection == "Rows") { input(name: "rts", type: "enum", title: bold("Text Size %"), options: textScale(), required: false, defaultValue: "100", submitOnChange: true, width:1) input(name: "rtc", type: "color", title: bold2("Text Color", rtc), required: false, defaultValue: "#000000", submitOnChange: true, width:2) input(name: "rbc", type: "color", title: bold2("Background Color", rbc), required: false, defaultValue: "#DDEEBB", submitOnChange: true, width:2) input(name: "rbo", type: "enum", title: bold("Background Opacity"), options: opacity(), required: false, defaultValue: "1", submitOnChange: true, width:2) input(name: "highlightSelectedRows", type: "enum", title: bold("Highlight Selected Rows"), options: ["True", "False"], required: false, defaultValue: "True", submitOnChange: true, width: 2, newLine: true, style:"margin-right:25px") if (highlightSelectedRows == "True" ) input(name: "rbs", type: "color", title: bold2("Selected Row - Background Color", rbs), required: false, defaultValue: "#FFE18F", submitOnChange: true, width:3) input(name: "highlightPinnedRows", type: "enum", title: bold("Highlight Pinned Rows"), options: ["True", "False"], required: false, defaultValue: "True", submitOnChange: true, width: 2, newLine: true, style:"margin-right:25px") if (highlightPinnedRows == "True") input(name: "rbpc", type: "color", title: bold2("Pinned Row - Background Color", rbpc), required: false, defaultValue: "#A7C7FB", submitOnChange: true, width: 3, style:"margin-right:25px" ) } if (settings.customizeSection == "Experimental") { paragraph "<b>There are no experimental settings at this time!<b>" } paragraph line(2) paragraph "<b>Important: You must do a " + red("Publish and Subscribe") + " for SmartGrid to receive updates and work correctly in polling mode or to update automatically in the above window!</b><br>" } //Start of Publish Section section(hideable: true, hidden: state.hidden.Publish, title: buttonLink('btnHidePublish', getSectionTitle("Publish"), 20)) { input(name: "myRemote", title: bold("Attribute to store the Remote?"), type: "enum", options: parent.allTileList(), required: false, submitOnChange: true, width: 3, defaultValue: 0, newLine: false) input(name: "myRemoteName", type: "text", title: bold("Name this Remote"), submitOnChange: true, width: 3, defaultValue: "New Remote", newLine: false, required: true) input(name: "tilesAlreadyInUse", type: "enum", title: bold("For Reference Only: Remotes in Use"), options: parent.getTileList(), required: false, defaultValue: "Remotes List", submitOnChange: true, width: 3) input(name: "eventTimeout", type: "enum", title: bold("Event Timeout (millis)"), required: false, multiple: false, defaultValue: "2000", options: ["0", "250", "500", "1000", "1500", "2000", "5000", "10000", "Never"], submitOnChange: true, width: 2) if (myRemoteName) app.updateLabel(myRemoteName) myText = "Publishing a remote is optional and only required if it will be used within a Hubitat dashboard. Remotes can be accessed directly via the URL's in the Endpoints section and bypass the Dashboard entirely if desired. " myText += "The <b>Event Timeout</b> period is how long Tile Builder will wait for subsequent events before publishing the table. Re-publishing a table will cause it to refresh on the dashboard. This setting batches multiple changes into a single publishing event. " myText += "Lowering the event timeout will make the table more responsive but also increase the number of refreshes. If the eventTimeout is set to 'Never' then you must manually click the refresh Icon to synchronise the table.<br>" myText += "If publishing to a dashboard is enabled then the The <b>Remote Name</b> given here will also be used as the name for this instance of Remote Builder. " myText += "Appending the name with your chosen remote number can make parent display more readable. Only <b>links</b> to the Local and Cloud Endpoints are stored in the Remote Builder Storage Device when publishing is enabled.<br>" myText += "<b>Note:</b> If you are not using the Remote within a Hubitat Dashboard you should <b>set the Event Timeout to Never</b> as republishing is not needed." paragraph summary("Publishing Help", myText) paragraph line(1) if ( state.compiledLocal != null && state.compiledCloud && settings.myRemote != null && myRemoteName != null) { input(name: "publishSubscribe", type: "button", title: "Publish and Subscribe", backgroundColor: "#27ae61", textColor: "white", submitOnChange: true, width: 2) input(name: "unsubscribe", type: "button", title: "Delete Subscription", backgroundColor: "#27ae61", textColor: "white", submitOnChange: true, width: 2) } else input(name: "cannotPublish", type: "button", title: "Publish and Subscribe", backgroundColor: "#D3D3D3", textColor: "black", submitOnChange: true, width: 2) } //End of Publish Section //Start of More Section section { paragraph line(2) input(name: "isMore", type: "bool", title: dodgerBlue("<b>More Options</b>"), required: false, multiple: false, defaultValue: false, submitOnChange: true, width: 2) if (isMore == true) { //Horizontal Line paragraph "In this section you can enable logging for various aspects of the program. This is usually used for debugging purposes and by default all logging other than errors is turned off by default. You can also rebuild the endpoints if you refresh the Oauth client secret." input(name: "isLogConnections", type: "bool", title: "<b>Record All Connection Requests?</b>", defaultValue: false, submitOnChange: true, width: 3) input(name: "isLogActions", type: "bool", title: "<b>Record All Action Requests?</b>", defaultValue: false, submitOnChange: true, width: 3) input(name: "isLogPublish", type: "bool", title: "<b>Enable Publishing logging?</b>", defaultValue: false, submitOnChange: true, width: 3) input(name: "isLogDeviceInfo", type: "bool", title: "<b>Enable Device Details logging?</b>", defaultValue: false, submitOnChange: true, width: 3) input(name: "isLogError", type: "bool", title: "<b>Log errors encountered?</b>", defaultValue: true, submitOnChange: true, width: 3, newLine: true) input(name: "isLogDebug", type: "bool", title: "<b>Enable Debug logging?</b>", defaultValue: false, submitOnChange: true, width: 3) input(name: "isLogTrace", type: "bool", title: "<b>Enable Trace logging?</b>", defaultValue: false, submitOnChange: true, width: 3) input(name: "rebuildEndpoints", type: "button", title: "Rebuild Endpoints", backgroundColor: "#27ae61", textColor: "white", submitOnChange: true, width: 12) } paragraph line(2) //Now add a footer. myDocURL = "<a href='https://github.com/GaryMilne/Hubitat-RemoteBuilder/blob/main/Remote_Builder_SmartGrid_Help.pdf' target=_blank> <i><b>Remote Builder - SmartGrid Help</b></i></a>" myText = '<div style="display: flex; justify-content: space-between;">' myText += '<div style="text-align:left;font-weight:small;font-size:12px"> <b>Documentation:</b> ' + myDocURL + '</div>' myText += '<div style="text-align:center;font-weight:small;font-size:12px">Version: ' + codeDescription + '</div>' myText += '<div style="text-align:right;font-weight:small;font-size:12px">Copyright 2022 - 2025</div>' myText += '</div>' paragraph myText } //End of More Section } } // Functions pertaining to the internal handling of the Groovy Parent application //Checks for critical Null values that can be introduced by the user by clicking "No Selection" in a variety of enum dialog. def checkNulls() { if (displayEndpoint == null) app.updateSetting("displayEndpoint", [value: "Local", type: "enum"]) if (localEndpointState == null ) app.updateSetting("localEndpointState", [value: "Enabled", type: "enum"]) if (cloudEndpointState == null ) app.updateSetting("cloudEndpointState", [value: "Disabled", type: "enum"]) if (pollInterval == null ) app.updateSetting("pollInterval", [value: "3", type: "enum"]) if (pollUpdateWidth == null ) app.updateSetting("pollUpdateWidth", [value: "3", type: "enum"]) if (pollUpdateDuration == null ) app.updateSetting("pollUpdateDuration", [value: "2", type: "enum"]) if (shuttleHeight == null ) app.updateSetting("shuttleHeight", [value: "2", type: "enum"]) if (commandTimeout == null ) app.updateSetting("commandTimeout", [value: "10", type: "enum"]) if (tvp == null ) app.updateSetting("tvp", [value: "3", type: "enum"]) if (thp == null ) app.updateSetting("thp", [value: "5", type: "enum"]) if (hts == null ) app.updateSetting("hts", [value: "100", type: "enum"]) if (hbo == null ) app.updateSetting("hbo", [value: "1", type: "enum"]) if (rts == null ) app.updateSetting("rts", [value: "90", type: "enum"]) if (rbo == null ) app.updateSetting("rbo", [value: "1", type: "enum"]) if (its1 == null ) app.updateSetting("its1", [value: "80", type: "enum"]) if (its2 == null ) app.updateSetting("its2", [value: "80", type: "enum"]) if (its3 == null ) app.updateSetting("its3", [value: "80", type: "enum"]) if (ita1 == null ) app.updateSetting("ita1", [value: "Center", type: "enum"]) if (ita2 == null ) app.updateSetting("ita2", [value: "Center", type: "enum"]) if (ita3 == null ) app.updateSetting("ita3", [value: "Center", type: "enum"]) if (state.compiledLocal == null) state.compiledLocal = "<span style='font-size:32px;color:yellow'>No Devices!</span>" if (state.compiledCloud == null) state.compiledCloud = "<span style='font-size:32px;color:yellow'>No Devices!</span>" } //Used to update variables when upgrading software versions. def updateVariables() { //This is a first time install so the variables should all be current. if (state.variablesVersion == null) { log.info("Initializing variablesVersion to: $codeVersion") state.variablesVersion = codeVersion } //Check to see if there has been an update. If there has the variablesVersion will be less than the codeVersion if (state.variablesVersion < 311) { log.info("Updating Variables to $codeVersion") //Add the newly created variables here such as: app.updateSetting("highlightPinnedRows", "True") app.updateSetting("rbpc", [value: "#A7C7FB", type: "color"]) app.updateSetting("highlightSelectedRows", "True") app.updateSetting("column9Header", "Room") app.updateSetting("column10Header", "Pin") app.updateSetting("hideColumn10", true) app.updateSetting("shuttleColor", [value: "#99C5FF", type: "color"]) app.updateSetting("tempUnits", [value: "°F", type: "enum"]) state.variablesVersion = codeVersion compile() } //Check to see if there has been an update. If there has the variablesVersion will be less than the codeVersion if (state.variablesVersion < 313) { log.info("Updating Variables to $codeVersion") app.updateSetting("sortHeaderHintAZ", [value: "#00FF00", type: "color"]) app.updateSetting("sortHeaderHintZA", [value: "#FF0000", type: "color"]) compile() } if (state.variablesVersion < 314) { log.info("Updating Variables to $codeVersion") app.updateSetting("tempDecimalPlaces", [value: "0 Decimal Places", type: "enum"]) state.variablesVersion = codeVersion compile() } if (state.variablesVersion < 316) { log.info("Updating Variables to $codeVersion") state.variablesVersion = codeVersion compile() } } // Receives a list of items and allows them to be selected for pinning def getPinnedItems(collection) { def itemList = [] collection.each { item -> itemList << item.displayName + " (" + item.getId() + ")" } return itemList } // Gets the state of the various lights that are being tracked and puts them into a JSON format for inclusion with the script def getJSON() { if (isLogTrace) log.trace("<b>Entering: GetJSON</b>") // List to hold device attribute data def deviceAttributesList = [] def eventData = [:] // Iterate through each device myDevices.each { device -> // Use LinkedHashMap to maintain the order of fields def deviceData = new LinkedHashMap() def deviceID = device.getId().toString() deviceData.put("ID", device.getId()) //Get the cached version of the name which may be short from device name modification deviceData.put("name", state.deviceList.find { it.ID == deviceID }?.name) def mySwitch = device.currentValue("switch") deviceData.put("switch", mySwitch) if ( containsDeviceID(myPinnedControls, deviceID) ) deviceData.put("pin", "on") //Get the device Type from cache so we don't have to calculate it every time. Makes the code on this end simpler. deviceType = state.deviceList.find { it.ID == deviceID }?.type deviceData.put("type", deviceType) switch (deviceType) { case 1: //Attributes are switch - ENUM ["on", "off"] deviceData.put("icon", getIcon(1, mySwitch)?.icon) deviceData.put("cl", getIcon(1, mySwitch)?.class) break case 2: //Attributes are: level - NUMBER, unit:% //def status = deviceData.put("level", device.currentValue("level")?.toInteger() ?: 100) deviceData.put("icon", getIcon(2, mySwitch)?.icon) deviceData.put("cl", getIcon(2, mySwitch)?.class) break case 3: //If the device has ColorTemperature but NOT ColorControl then it is a CT only device. Attributes are: colorName - STRING colorTemperature - NUMBER, unit:°K deviceData.put("level", device.currentValue("level")?.toInteger() ?: 100) deviceData.put("CT", device.currentValue("colorTemperature")?.toInteger() ?: 2000) deviceData.put("icon", getIcon(3, mySwitch)?.icon) deviceData.put("cl", getIcon(3, mySwitch)?.class) break case 4: //If the device has ColorControl but NOT ColorTemperature then it is an RGB device. Attributes are: RGB - STRING color - STRING colorName - STRING hue - NUMBER saturation - NUMBER, unit:% deviceData.put("level", device.currentValue("level")?.toInteger() ?: 100) deviceData.put("CT", device.currentValue("colorTemperature")?.toInteger() ?: 2000) def hsvMap = [hue: device.currentValue("hue") ?: 100, saturation: device.currentValue("saturation") ?: 100, value: device.currentValue("level")?.toInteger() ?: 100 ] def color = getHEXfromHSV(hsvMap) deviceData.put("color", color) deviceData.put("icon", getIcon(4, mySwitch)?.icon) deviceData.put("cl", getIcon(4, mySwitch)?.class) break case 5: //If the device has ColorControl AND ColorTemperature then it is a RGBW device. Attributes are: RGB - STRING color - STRING colorName - STRING hue - NUMBER saturation - NUMBER, unit:% +++++ colorTemperature - NUMBER, unit:°K deviceData.put("level", device.currentValue("level")?.toInteger() ?: 100) deviceData.put("CT", (device.currentValue("colorTemperature")?.toInteger() ?: 2000)) def hsvMap = [hue: device.currentValue("hue") ?: 100, saturation: device.currentValue("saturation") ?: 100, value: device.currentValue("level")?.toInteger() ?: 100 ] def color = getHEXfromHSV(hsvMap) deviceData.put("color", color) deviceData.put("colorMode", device.currentValue("colorMode")) deviceData.put("icon", getIcon(5, mySwitch)?.icon) deviceData.put("cl", getIcon(5, mySwitch)?.class) break case 10: //Check for valves - ENUM ["open", "closed"] myValve = device.currentValue("valve") mySwitch = (myValve == "open") ? "on" : "off" deviceData.put("switch", mySwitch) deviceData.put("icon", getIcon(10, mySwitch)?.icon) deviceData.put("cl", getIcon(10, mySwitch)?.class) break case 11: //Check for locks - states for lock are: ENUM ["locked", "unlocked with timeout", "unlocked", "unknown"] //Only locked and unlocked are implemented. myLock = device.currentValue("lock") mySwitch = (myLock == "locked") ? "on" : "off" deviceData.put("switch", mySwitch) deviceData.put("icon", getIcon(11, mySwitch)?.icon) deviceData.put("cl", getIcon(11, mySwitch)?.class) break case 12: //Check for Fans - States for speed are: ENUM ["low","medium-low","medium","medium-high","high","on","off","auto"] mySpeed = device.currentValue("speed") mySwitch = (mySpeed == "off") ? "off" : "on" deviceData.put("speed", mySpeed) deviceData.put("switch", mySwitch) deviceData.put("icon", getIcon(12, mySwitch)?.icon) deviceData.put("cl", getIcon(12, mySpeed)?.class) break case 13: //Check for Garage Doors - States are: ENUM ["unknown", "open", "closing", "closed", "opening"] myDoor = device.currentValue("door") mySwitch = (myDoor == "closed") ? "on" : "off" deviceData.put("door", myDoor) deviceData.put("switch", mySwitch) deviceData.put("icon", getIcon(13, myDoor)?.icon) deviceData.put("cl", getIcon(13, myDoor)?.class) break case 14: // Check for Shades and exclude blinds - States for windowShade are: ENUM ["opening", "partially open", "closed", "open", "closing", "unknown"] myStatus = device.currentValue("windowShade") myPosition = device.currentValue("position") mySwitch = (myStatus == "closed") ? "off" : "on" deviceData.put("windowShade", myStatus) deviceData.put("position", myPosition) deviceData.put("switch", mySwitch) deviceData.put("icon", getIcon(14, myStatus)?.icon) deviceData.put("cl", getIcon(14, myStatus)?.class) break case 15: // Check for Blinds - States for windowBlind are: ENUM ["opening", "partially open", "closed", "open", "closing", "unknown"] myStatus = device.currentValue("windowShade") myPosition = device.currentValue("position") mySwitch = (myStatus == "closed") ? "off" : "on" deviceData.put("position", device.currentValue("position")) deviceData.put("tilt", Math.round(device.currentValue("tilt") * 0.9)) deviceData.put("switch", mySwitch) deviceData.put("icon", getIcon(15, myStatus)?.icon) deviceData.put("cl", getIcon(15, myStatus)?.class) break case 16: //Check for Audio Volume - States for Mute are: ENUM ["unmuted", "muted"] def myStatus = device.currentValue("mute") mySwitch = (myStatus == "muted") ? "off" : "on" deviceData.put("switch", mySwitch) deviceData.put("volume", device.currentValue("volume")) deviceData.put("icon", getIcon(16, mySwitch)?.icon) deviceData.put("cl", getIcon(16, mySwitch)?.class) break default: break } deviceDetails = getDeviceInfo(device, deviceData.get("type") ) //Gather event information if needed for either of the two columns. if (hideColumn7 == false ) deviceData.put("i1", deviceDetails."${info1Source}") if (hideColumn8 == false ) deviceData.put("i2", deviceDetails."${info2Source}") if (hideColumn9 == false ) deviceData.put("i3", deviceDetails."${info3Source}") // Add device data to the list deviceAttributesList << deviceData //log.info("Device: $deviceData.name is type: $deviceData.type and data is: $deviceData") } myContacts.each { device -> def deviceData = new LinkedHashMap() def deviceID = device.getId().toString() deviceData.put("ID", deviceID) //Get the cached version of the name which may be short from device name modification deviceData.put("name", state.deviceList.find { it.ID == deviceID }?.name) deviceData.put("type", 31) def myContact = device.currentValue("contact") deviceData.put("switch", myContact) deviceData.put("icon", getIcon(31, myContact)?.icon) deviceData.put("cl", getIcon(31, myContact)?.class) //See if it is in the pinned list. If it is then it is always shown regardless of the state if ( containsDeviceID(myPinnedContacts, device.getId() )) { deviceData.put("pin", "on") ; deviceAttributesList << deviceData } else { if (onlyOpenContacts == "False" ) deviceAttributesList << deviceData if (onlyOpenContacts == "True" && myContact == "open") deviceAttributesList << deviceData } //Gather event information if needed for either of the two columns. deviceDetails = getDeviceInfo(device, 31 ) if (hideColumn7 == false ) deviceData.put("i1", deviceDetails."${info1Source}") if (hideColumn8 == false ) deviceData.put("i2", deviceDetails."${info2Source}") if (hideColumn9 == false ) deviceData.put("i3", deviceDetails."${info3Source}") } myTemps.each { device -> def deviceData = new LinkedHashMap() def deviceID = device.getId().toString() deviceData.put("ID", deviceID) //Get the cached version of the name which may be short from device name modification deviceData.put("name", state.deviceList.find { it.ID == deviceID }?.name) deviceData.put("type", 32) def myTemperature = device.currentValue("temperature") as float if (tempDecimalPlaces == "0 Decimal Places") { myTemperature = myTemperature.round(0).toInteger() } if (tempDecimalPlaces == "1 Decimal Place") { myTemperature = myTemperature.round(1) } deviceData.put("switch", myTemperature.toString() + tempUnits) deviceData.put("icon", getIcon(32, "temp")?.icon) deviceData.put("cl", getIcon(32, "temp")?.class) //See if it is in the pinned list. If it is then it is always shown ragardless of the state if ( containsDeviceID(myPinnedTemps, device.getId() )) { deviceData.put("pin", "on") ; deviceAttributesList << deviceData } else { if (onlyReportOutsideRange == "False" ) deviceAttributesList << deviceData if ( onlyReportOutsideRange == "True" && ( myTemperature < minTemp.toInteger() || myTemperature > maxTemp.toInteger() ) ) deviceAttributesList << deviceData } //Gather event information if needed for either of the two columns. deviceDetails = getDeviceInfo(device, 32 ) if (hideColumn7 == false ) deviceData.put("i1", deviceDetails."${info1Source}") if (hideColumn8 == false ) deviceData.put("i2", deviceDetails."${info2Source}") if (hideColumn9 == false ) deviceData.put("i3", deviceDetails."${info3Source}") } myLeaks.each { device -> // Use LinkedHashMap to maintain the order of fields def deviceData = new LinkedHashMap() def deviceID = device.getId().toString() deviceData.put("ID", deviceID) //Get the cached version of the name which may be short from device name modification deviceData.put("name", state.deviceList.find { it.ID == deviceID }?.name) deviceData.put("type", 33) def myLeak = device.currentValue("water") deviceData.put("switch", myLeak) deviceData.put("icon", getIcon(33, myLeak)?.icon) deviceData.put("cl", getIcon(33, myLeak)?.class) //See if it is in the pinned list. If it is then it is always shown ragardless of the state if ( containsDeviceID(myPinnedLeaks, device.getId() )) { deviceData.put("pin", "on") ; deviceAttributesList << deviceData } else { if (onlyWetSensors == "False" ) deviceAttributesList << deviceData if (onlyWetSensors == "True" && myLeak == "wet") deviceAttributesList << deviceData } //Gather event information if needed for either of the two columns. deviceDetails = getDeviceInfo(device, 33 ) if (hideColumn7 == false ) deviceData.put("i1", deviceDetails."${info1Source}") if (hideColumn8 == false ) deviceData.put("i2", deviceDetails."${info2Source}") if (hideColumn9 == false ) deviceData.put("i3", deviceDetails."${info3Source}") } // Convert the list of device attributes to JSON format def compactJSON = JsonOutput.toJson(deviceAttributesList) if (isLogDebug) { // Pretty print the JSON output def prettyJSON = JsonOutput.prettyPrint(compactJSON) log.debug("getJSON Output: $prettyJSON") } //Save the compact JSON. This is the version that is collected by the client. state.JSON = compactJSON } //Checks the cached list to see if the received deviceID is part of that list. This is used to determine whether something is pinned or not. def containsDeviceID(deviceList, deviceID) { def pattern = /\((\d+)\)/ // Regular expression to extract the ID in parentheses return deviceList.any { item -> def matcher = (item =~ pattern) matcher && matcher[0][1] == deviceID.toString() } } // Return the appropriate icon and class to match the type and deviceState def getIcon(type, deviceState) { //log.info ("Received: $type $deviceState)") def icons = [ 1 : [on: [icon: "toggle_on", class: "on"], off: [icon: "toggle_off", class: "off"]], 2 : [on: [icon: "lightbulb", class: "on"], off: [icon: "light_off", class: "off"]], 3 : [on: [icon: "lightbulb", class: "on"], off: [icon: "light_off", class: "off"]], 4 : [on: [icon: "lightbulb", class: "on"], off: [icon: "light_off", class: "off"]], 5 : [on: [icon: "lightbulb", class: "on"], off: [icon: "light_off", class: "off"]], 10 : [on: [icon: "water_pump", class: "on"], off: [icon: "valve", class: "off"]], 11 : [on: [icon: "lock", class: "good"], off: [icon: "lock_open", class: "warn"]], 12 : [on: [icon: "mode_fan", class: "spinning"], off: [icon: "mode_fan_off", class: "off"], low: [icon: "mode_fan", class: "spin-low"], medium: [icon: "mode_fan", class: "spin-medium"], high: [icon: "mode_fan", class: "spin-high"]], 13 : [closed: [icon: "garage_door", class: "good"], open: [icon: "garage", class: "warn"], opening: [icon: "arrow_upward", class: "blinking"], closing: [icon: "arrow_downward", class: "blinking"]], 14 : [open: [icon: "window_closed", class: "on"], closed: [icon: "roller_shades_closed", class: "off"], opening: [icon: "arrow_upward", class: "blinking"], closing: [icon: "arrow_downward", class: "blinking"], 'partially open': [icon: "roller_shades", class: "partial"]], 15 : [open: [icon: "window_closed", class: "on"], closed: [icon: "blinds_closed", class: "off"], opening: [icon: "arrow_upward", class: "blinking"], closing: [icon: "arrow_downward", class: "blinking"], 'partially open': [icon: "blinds", class: "on"]], 16 : [on: [icon: "volume_up", class: "on"], off: [icon: "volume_off", class: "off"]], 20 : [on: [icon: "mode_cool", class: "cooling"], off: [icon: "mode_cool_off", class: "inactive"]], 21 : [on: [icon: "mode_heat", class: "heating"], off: [icon: "mode_heat_off", class: "inactive"]], 22 : [on: [icon: "mode_dual", class: "dual-mode"], off: [icon: "mode_dual_off", class: "inactive"]], 23 : [on: [icon: "emergency_heat", class: "emergency"], off: [icon: "emergency_heat", class: "inactive"]], // Sensors start at 31 31 : [open: [icon: "expand_content", class: "warn"], closed: [icon: "collapse_content", class: "off"]], 32 : [temp: [icon: "device_thermostat", class: "off"]], 33 : [wet: [icon: "water_drop", class: "warn"], dry: [icon: "format_color_reset", class: "off"] ] ] // Retrieve the entry for the given type and deviceState def result = icons[type]?.get(deviceState) // Return the result if found, otherwise return a default structure //log.info ("Returning: $result") return result ?: [icon: "error", class: "warn"] } //Check to see if a given infoSource is in use across all three possibilities def isInfoSource(source){ //log.info("Request: $source -- info1Source: $info1Source - info2Source: $info2Source - info3Source: $info3Source") def match = false if (info1Source == source) match = true if (info2Source == source) match = true if (info3Source == source) match = true //if (match == true) log.info("Match for source: $source") return match } //Gets information about the lastActive, lastInactive etc and put it into a map. Data from selected fields will be mapped into the Info columns. def getDeviceInfo(device, type){ def lastActiveEvent def lastInactiveEvent def lastActive def lastInactive def lastActiveInstant def lastInactiveInstant def lastActiveDuration def lastSeen def lastSeenElapsed def roomName def colorName def colorMode def power def healthStatus def energy def network def colorTemperature def deviceTypeName def lastActivity def battery def temperature def ID = device?.getId() def deviceName = device.getLabel() //Get the appropriate piece of information only if it has been requested. if (isInfoSource("roomName")) roomName = device?.getRoomName() if (isInfoSource("colorName")) colorName = device?.currentValue("colorName") if (isInfoSource("colorMode")) colorMode = device?.currentValue("colorMode") if (isInfoSource("power")) power = device?.currentValue("power") if (isInfoSource("healthStatus")) healthStatus = device?.currentValue("healthStatus") if (isInfoSource("energy")) energy = device?.currentValue("energy") if (isInfoSource("network")) network = getNetworkType(device?.getDeviceNetworkId()) if (isInfoSource("deviceTypeName")) deviceTypeName = getDeviceTypeInfo(type) if (isInfoSource("battery") && device.hasCapability("Battery") ) battery = device?.currentValue("battery") + "%" if (isInfoSource("colorTemperature") && device.hasCapability("ColorTemperature") ) colorTemperature = device?.currentValue("colorTemperature") + "°K" if (isInfoSource("temperature") && device.hasCapability("TemperatureMeasurement") ) { myTemp = device?.currentValue("temperature") def roundedValue = Math.round(myTemp) // Returns a long temperature = roundedValue.toInteger().toString() + tempUnits } //log.info("deviceName: $deviceName type: $type") if (isInfoSource("lastActive") || isInfoSource("lastActiveDuration") || isInfoSource("lastInactive") || isInfoSource("lastInactiveDuration") ) { switch(type){ case [1,2,3,4,5]: //Lights lastActiveEvent = device.events(max: 500) .findAll { it.name == "switch" && it?.value == "on" } .sort { -it.date.time } ?.with { it.isEmpty() ? null : it.first() } lastInactiveEvent = device.events(max: 500) .findAll { it.name == "switch" && it?.value == "off" } .sort { -it.date.time } ?.with { it.isEmpty() ? null : it.first() } break case 10: //Valve lastActiveEvent = device.events(max: 500) .findAll { it.name == "valve" && it?.value == "open" } .sort { -it.date.time } ?.with { it.isEmpty() ? null : it.first() } lastInactiveEvent = device.events(max: 500) .findAll { it.name == "valve" && it?.value == "closed" } .sort { -it.date.time } ?.with { it.isEmpty() ? null : it.first() } break case 11: //Lock lastActiveEvent = device.events(max: 500) .findAll { it.name == "lock" && it?.value == "locked" } .sort { -it.date.time } ?.with { it.isEmpty() ? null : it.first() } lastInactiveEvent = device.events(max: 500) .findAll { it.name == "lock" && it?.value == "unlocked" } .sort { -it.date.time } ?.with { it.isEmpty() ? null : it.first() } break case 12: //Fan lastActiveEvent = device.events(max: 500) .findAll { it.name == "speed" && it?.value != "off" } .sort { -it.date.time } ?.with { it.isEmpty() ? null : it.first() } lastInactiveEvent = device.events(max: 500) .findAll { it.name == "speed" && it?.value == "off" } .sort { -it.date.time } ?.with { it.isEmpty() ? null : it.first() } break case 13: //Garage Door lastActiveEvent = device.events(max: 500) .findAll { it.name == "door" && it?.value == "closed" } .sort { -it.date.time } ?.with { it.isEmpty() ? null : it.first() } lastInactiveEvent = device.events(max: 500) .findAll { it.name == "door" && it?.value != "closed" } .sort { -it.date.time } ?.with { it.isEmpty() ? null : it.first() } break case [14,15]: //Shades and Blinds lastActiveEvent = device.events(max: 500) .findAll { it.name == "windowShade" && it?.value == "open" } .sort { -it.date.time } ?.with { it.isEmpty() ? null : it.first() } lastInactiveEvent = device.events(max: 500) .findAll { it.name == "windowShade" && it?.value != "open" } .sort { -it.date.time } ?.with { it.isEmpty() ? null : it.first() } break case 31: //Contact Sensor lastActiveEvent = device.events(max: 500) .findAll { it.name == "contact" && it?.value == "open" } .sort { -it.date.time } ?.with { it.isEmpty() ? null : it.first() } lastInactiveEvent = device.events(max: 500) .findAll { it.name == "contact" && it?.value == "closed" } .sort { -it.date.time } ?.with { it.isEmpty() ? null : it.first() } break case 33: //Water Sensor lastActiveEvent = device.events(max: 500) .findAll { it.name == "water" && it?.value == "wet" } .sort { -it.date.time } ?.with { it.isEmpty() ? null : it.first() } lastInactiveEvent = device.events(max: 500) .findAll { it.name == "water" && it?.value == "dry" } .sort { -it.date.time } ?.with { it.isEmpty() ? null : it.first() } break } if (lastActiveEvent != null) { lastActive = formatTime(lastActiveEvent?.getDate(), defaultDateTimeFormat.toInteger() ?: 3) lastActiveDuration = formatTime(lastActiveEvent?.getDate(), defaultDurationFormat.toInteger() ?: 21 ) lastActiveInstant = formatTime(lastActiveEvent?.getDate(), 0 ) } if (lastInactiveEvent != null) { lastInactive = formatTime(lastInactiveEvent?.getDate(), defaultDateTimeFormat.toInteger() ?: 3) lastInactiveDuration = formatTime(lastInactiveEvent?.getDate(), defaultDurationFormat.toInteger() ?: 21 ) lastInactiveInstant = formatTime(lastInactiveEvent?.getDate(), 0 ) } if (lastInactive != null && lastActive != null) { durations = getDuration(lastActiveInstant, lastInactiveInstant) lastActiveDuration = durations.lastActiveDuration lastInactiveDuration = durations.lastInactiveDuration } } if (isInfoSource("lastSeen") || isInfoSource("lastSeenElapsed") ) { lastActivity = device?.getLastActivity() if (lastActivity != null) { def timestamp = lastActivity.time lastSeen = formatTime(timestamp, defaultDateTimeFormat.toInteger() ?: 3) def durations = getDuration(timestamp, now()) lastSeenElapsed = durations.lastActiveDuration } } def result = [lastActive: lastActive, lastInactive: lastInactive, lastInactiveInstant: lastInactiveInstant, lastActiveInstant: lastActiveInstant, lastActiveDuration: lastActiveDuration, lastInactiveDuration: lastInactiveDuration, roomName: roomName, colorName: colorName, colorMode: colorMode, power: power, healthStatus: healthStatus, energy: energy, ID: ID, network: network, deviceTypeName: deviceTypeName, lastSeen: lastSeen, lastSeenElapsed: lastSeenElapsed, battery: battery, temperature: temperature, colorTemperature: colorTemperature ].collectEntries { key, value -> [key, value != null ? value : invalidAttribute.toString()] } //log.info("Returning map: $result") return result } // Function to determine network type based on DNI length def getNetworkType(dni) { def networkTypes = [ 2: "Z-Wave", 4: "Zigbee", 8: "LAN", 36: "Virtual" ] return networkTypes[dni?.length()] ?: "Other" } //Can return either a type name or a type number def getDeviceTypeInfo(input) { def maps = createDeviceTypeMap() def typeMap = maps.typeMap def nameToNumberMap = maps.nameToNumberMap if (input instanceof Number) { return typeMap[input] ?: "Unknown device type number" } else if (input instanceof String) { return nameToNumberMap[input] ?: "Unknown device type name" } else { return "Invalid input" } } // Takes a JSON list of devices and changes, and applies them. def applyChangesToDevices(changes) { if (isLogTrace) log.trace("<b>Entering: applyChangesToDevices</b>") if (isLogDeviceInfo) log.debug("Changes are: $changes") // Define a map of actions def commandMap = [ 'switch' : { ID, type, newValue -> handleSwitch(ID, type, newValue) }, 'level' : { ID, _, newValue -> ID.setLevel(newValue, 0.4) }, 'volume' : { ID, _, newValue -> ID.setVolume(newValue) }, 'position': { ID, _, newValue -> ID.setPosition(newValue) }, 'tilt' : { ID, _, newValue -> ID.setTiltLevel(Math.round(newValue * (100 / 90))) }, 'speed' : { ID, _, newValue -> ID.setSpeed(newValue) }, 'name' : { ID, _, newValue -> ID.setLabel(newValue) }, 'CT' : { ID, _, newValue -> ID.setColorTemperature(newValue, null, 0.2) }, 'color' : { ID, _, newValue -> setDeviceColor(ID, newValue) } ] // Iterate over changes changes.each { change -> def device = findDeviceById(change.ID) if (device) { def type = change.type change.changes.each { key, values -> def newValue = values[1] // Get the new value for each property def command = commandMap[key] if (command) { command(device, type, newValue) } else { if (isLogDebug) log.warn("Unhandled change: $key") } } } } } // Handles switch-type actions for different device types def handleSwitch(device, type, newValue) { def actionMap = [ [1, 2, 3, 4, 5]: { value -> value == "on" ? device.on() : device.off() }, [10]: { value -> value == "on" ? device.open() : device.close() }, [11]: { value -> value == "on" ? device.lock() : device.unlock() }, [12]: { value -> value == "on" ? device.setSpeed("on") : device.setSpeed("off") }, [13]: { value -> value == "on" ? device.close() : device.open() }, [14, 15]: { value -> value == "on" ? device.open() : device.close() }, [16]: { value -> value == "on" ? device.unmute() : device.mute() } ] // Iterate over the map to find the matching key actionMap.each { keys, action -> if (keys.contains(type as Integer)) { action(newValue) // Execute the action if keys match return } } } // Converts a hex color to HSV and sets it for the device def setDeviceColor(device, hexColor) { def RGB = hubitat.helper.ColorUtils.hexToRGB("$hexColor") def HSV = hubitat.helper.ColorUtils.rgbToHSV(RGB) device.setColor([hue: HSV[0], saturation: HSV[1], level: HSV[2]]) } //******************************************************************************************************************************************************************************************* //************** //************** Time and Date Related Functions //************** //******************************************************************************************************************************************************************************************* //This is derived from the formatTime function used in Grid but in this case the only Time format received is from Events which are always "java.sql.Timestamp" or from lastActivity which are of type Long. //Receives a time as an event timestamp and converts it into one of many alternate time formats. def formatTime(timeValue, int format) { def isLogDateTime = false if (isLogDateTime) log.info("<b>formatTime: Time received is: $timeValue and requesting format: $format</b>") def myLongTime // N/A means the requested attribute was not found. if (timeValue == "N/A") return 0 def myClass = getObjectClassName(timeValue) if (myClass == "java.sql.Timestamp" ) myLongTime = timeValue.getTime() else myLongTime = timeValue if (isLogDateTime) log.info("<b>Received timestamp: $timeValue - Converted to: $myLongTime") Date myDateTime = new Date(myLongTime) if (myDateTime == null || myDateTime == "null") return "N/A" if (format == 0) return myLongTime if (format == 1) targetFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS") //Return the selected DateTime format if (2 <= format && format <= 20) targetFormat = new SimpleDateFormat(getDateTimeFormatDescription(format)) //This is the elapsed time calculation including seconds if (format == 21 || format == 22) { // Get the number of seconds this event occurred after the epoch def diff = ((now() - myLongTime) / 1000).toLong() if (format == 21) elapsedTime = convertSecondsToDHMS(diff, true) if (format == 22) elapsedTime = convertSecondsToDHMS(diff, false) if (isLogDateTime && isLogDetails) log.info("Elapsedtime is $elapsedTime") return elapsedTime } //This is the remaining time calculation including seconds if (format == 23 || format == 24) { // Get the number of seconds this event occurred after the epoch def diff = ((myLongTime - now()) / 1000).toLong() if (format == 23) remainingTime = convertSecondsToDHMS(diff, true) if (format == 24) remainingTime = convertSecondsToDHMS(diff, false) if (isLogDateTime && isLogDetails) log.info("Remaining time is $remainingTime") return remainingTime } //Depending on the mode the date may already have been converted to a string using a cleanup. If that is the case a second conversion will fail so we will just return the original converted value. try { Date date = new Date(myLongTime) String formattedDateTime = targetFormat.format(date) if (isLogDateTime && isLogDetails) log.info("formatTime: Returning date $formattedDateTime") return formattedDateTime } catch (Exception ignored) { return timeValue } } //Get the Index that corresponds to the Text Description that is used by formatTime() to get the right format. static def getDateTimeFormatIndex(description) { def map = dateFormatsMap() for (entry in map) { if (entry.value == description) return entry.key } return 0 // If description not found } //Get the Index that corresponds to the Text Description that is used by formatTime() to get the right format. static def getDurationFormatIndex(description) { def map = durationFormatsMap() for (entry in map) { if (entry.value == description) return entry.key } return 0 // If description not found } //Get the Date Time text description that corresponds to an index. static def getDateTimeFormatDescription(format) { def dateFormatString = dateFormatsMap()[format] def dateFormat = dateFormatString.replaceAll('To: ', '') //log.info ("The requested format is: $dateFormat") return dateFormat } //Calculates the elapsed time since an event and returns the string value. Return value omits days if the value is 0. String convertSecondsToDHMS(long seconds, boolean includeSeconds) { if (isLogDateTime) log.info("<convertSecondsToDHMS: Received: $seconds") def days = (seconds / (24 * 3600)) as int def hours = ((seconds % (24 * 3600)) / 3600) as int def minutes = ((seconds % 3600) / 60) as int def remainingSeconds = (seconds % 60) as int // Check if days are greater than 0 def daysString = days > 0 ? "${days}d " : "" def hoursString = hours > 0 ? "${hours}h " : "" if (!includeSeconds) return "${daysString}${hoursString} ${minutes}m" else return "${daysString}${hoursString}${minutes}m ${remainingSeconds}s" } //Gets the duration between two instants (lastActive / lastInactive) that would typically be an on/off pairing. def getDuration(long lastActiveEvent, long lastInactiveEvent) { if (isLogTrace) log.trace("<b>getDuration: Received $lastActiveEvent, $lastInactiveEvent</b>") def includeSeconds = false long diff = lastActiveEvent - lastInactiveEvent if (defaultDurationFormat.toInteger() == 21) includeSeconds = true else includeSeconds = false //Invalid if (diff == 0) return [currentRunTime: invalidAttribute.toString(), lastRunTime: invalidAttribute.toString() ] //If there is no difference then the duration is not valid. //LastOn is more recent than LastOff so the device must be on and still running. So diff is equal to the amount of current runtime. if (diff > 0) { diff = (now() - lastActiveEvent) / 1000 lastActiveDuration = "<span style='color: green;'>" + convertSecondsToDHMS(diff, includeSeconds).toString() + "</span>" //log.info ("Device is ON and current runtime is: $lastActiveDuration") diff = (lastActiveEvent - lastInactiveEvent) / 1000 lastInactiveDuration = convertSecondsToDHMS(diff, includeSeconds).toString() //log.info ("Device is ON and last off duration is: $lastInactiveDuration") } //LastOff is more recent than LastOn so the device must be off. So diff is equal to the amount of the last runtime (from lastActive -> lastInactive) if (diff <= 0) { diff = (now() - lastInactiveEvent) / 1000 lastInactiveDuration = "<span style='color: red;'>" + convertSecondsToDHMS(diff, includeSeconds).toString() + "</span>" diff = (lastInactiveEvent - lastActiveEvent) / 1000 lastActiveDuration = "<span style='color: green;'>" + convertSecondsToDHMS(diff, includeSeconds).toString() + "</span>" lastActiveDuration = convertSecondsToDHMS(diff, includeSeconds).toString() } result = [lastActiveDuration: lastActiveDuration, lastInactiveDuration: lastInactiveDuration ] //log.info ("Result is: $result") return result } //******************************************************************************************************************************************************************************************* //************** //************** Compile Time Functions //************** //******************************************************************************************************************************************************************************************* //Compress the fixed components text output and generate the version that will be used by the browser. def compile(){ try{ if (isLogTrace) log.trace("<b>Entering: Compile</b>") if (isLogDebug) log.debug("Running Compile") def html1 = myHTML() def content = condense(html1) def localContent def cloudContent //Sets the size of the controls within the SmartGrid content = content.replace('#controlSize#', controlSize.toString() ) //The AppID is used as a unique key for saving items in local and session storage so that preferences are tied to the each unique SmartGrid state.AppID = state.accessToken.toString()[-4..-1] content = content.replace('#AppID#', state.AppID ) //Table Horizontal Alignment if (ha == "Stretch") content = content.replace('#ha#', "stretch" ) if (ha == "Left") content = content.replace('#ha#', "flex-start" ) if (ha == "Center") content = content.replace('#ha#', "center" ) if (ha == "Right") content = content.replace('#ha#', "flex-end" ) //Table Padding content = content.replace('#thp#', thp ) content = content.replace('#tvp#', tvp ) //Column Headers content = content.replace('#column3Header#', toHTML(column3Header) ) // Column 3 header text content = content.replace('#column5Header#', toHTML(column5Header) ) // Column 5 header text content = content.replace('#column6Header#', toHTML(column6Header) ) // Column 6 header text //Forced Column Widths - If the columns are marked as hidden change the width to zero. if (hideColumn3) { content = content.replace('#column3Width#', '0') } else { content = content.replace('#column3Width#', column3Width.toString()) } if (hideColumn5) { content = content.replace('#column5Width#', '0') } else { content = content.replace('#column5Width#', column5Width.toString()) } if (hideColumn6) { content = content.replace('#column6Width#', '0') } else { content = content.replace('#column6Width#', column6Width.toString()) } if ( highlightPinnedRows == "True" ) content = content.replace('#rbpc#', rbpc ) if ( highlightSelectedRows == "True" ) content = content.replace('#rbs#', rbs ) else content = content.replace('#rbs#', "00000000" ) //Info Columns content = content.replace('#Info1#', toHTML(column7Header) ) // Info 1 Header Text content = content.replace('#its1#', its1 ) // Info 1 Text Size content = content.replace('#ita1#', ita1 ) // Info 1 Text Alignment content = content.replace('#Info2#', toHTML(column8Header) ) // Info 2 Header Text content = content.replace('#its2#', its2 ) // Info 2 Text Size content = content.replace('#ita2#', ita2 ) // Info 2 Text Alignment content = content.replace('#Info3#', toHTML(column9Header) ) // Info 3 Header Text content = content.replace('#its3#', its3 ) // Info 3 Text Size content = content.replace('#ita3#', ita3 ) // Info 3 Text Alignment if (tt == "?") { content = content.replace('#titleDisplay#', 'none' ) } else { content = content.replace('#titleDisplay#', 'block' ) // Display Title or not content = content.replace('#tt#', toHTML(tt) ) // Title Text content = content.replace('#ts#', ts ) // Title Size content = content.replace('#tp#', tp ) // Title Padding content = content.replace('#ta#', ta ) // Title Alignment content = content.replace('#tc#', tc ) // Title Color def mytb = convertToHex8(tb, to.toFloat()) //Calculate the new color including the opacity. content = content.replace('#tb#', mytb ) // Title Background Color// Display Title or not } content = content.replace('#hts#', hts ) // Header Text Size content = content.replace('#htc#', htc ) // Header Text Color content = content.replace('#sortHeaderHintAZ#', sortHeaderHintAZ ) content = content.replace('#sortHeaderHintZA#', sortHeaderHintZA ) def myhbc = convertToHex8(hbc, hbo.toFloat()) //Calculate the new color including the opacity. content = content.replace('#hbc#', myhbc ) // Header Background Color content = content.replace('#rts#', rts ) // Row Text Size content = content.replace('#rtc#', rtc ) // Row Text Color def myrbc = convertToHex8(rbc, rbo.toFloat()) //Calculate the new color including the opacity. content = content.replace('#rbc#', myrbc ) // Row Background Color content = content.replace('#rbs#', rbs ) // Row Background Color Selected //Hide unwanted columns /*content = content.replace('#hideColumn1#', hideColumn1 ? 'collapse' : 'visible') content = content.replace('#hideColumn2#', hideColumn2 ? 'collapse' : 'visible') content = content.replace('#hideColumn3#', hideColumn3 ? 'collapse' : 'visible') content = content.replace('#hideColumn4#', hideColumn4 ? 'collapse' : 'visible') content = content.replace('#hideColumn5#', hideColumn5 ? 'collapse' : 'visible') content = content.replace('#hideColumn6#', hideColumn6 ? 'collapse' : 'visible') content = content.replace('#hideColumn7#', hideColumn7 ? 'collapse' : 'visible') content = content.replace('#hideColumn8#', hideColumn8 ? 'collapse' : 'visible') content = content.replace('#hideColumn9#', hideColumn9 ? 'collapse' : 'visible') content = content.replace('#hideColumn10#', hideColumn10 ? 'collapse' : 'visible')*/ //Hide unwanted columns content = content.replace('#hideColumn1#', hideColumn1 ? 'none' : 'table-cell') content = content.replace('#hideColumn2#', hideColumn2 ? 'none' : 'table-cell') content = content.replace('#hideColumn3#', hideColumn3 ? 'none' : 'table-cell') content = content.replace('#hideColumn4#', hideColumn4 ? 'none' : 'table-cell') content = content.replace('#hideColumn5#', hideColumn5 ? 'none' : 'table-cell') content = content.replace('#hideColumn6#', hideColumn6 ? 'none' : 'table-cell') content = content.replace('#hideColumn7#', hideColumn7 ? 'none' : 'table-cell') content = content.replace('#hideColumn8#', hideColumn8 ? 'none' : 'table-cell') content = content.replace('#hideColumn9#', hideColumn9 ? 'none' : 'table-cell') content = content.replace('#hideColumn10#', hideColumn10 ? 'none' : 'table-cell') content = content.replace('#BrowserTitle#', myRemoteName) content = content.replace('#pollInterval#', (pollInterval.toInteger() * 1000).toString() ) content = content.replace('#pollUpdateColorSuccess#', pollUpdateColorSuccess) content = content.replace('#pollUpdateColorFail#', pollUpdateColorFail) content = content.replace('#pollUpdateWidth#', pollUpdateWidth) content = content.replace('#pollUpdateDuration#', (pollUpdateDuration.toInteger() * 1000).toString() ) content = content.replace('#commandTimeout#', (commandTimeout.toInteger() * 1000).toString() ) if (isPollingEnabled == "Enabled") content = content.replace('#isPollingEnabled#', "true") if (isPollingEnabled == "Disabled") content = content.replace('#isPollingEnabled#', "false") content = content.replace('#shuttleColor#', shuttleColor) content = content.replace('#shuttleHeight#', shuttleHeight) //Put the proper statement in for the Materials Font. It's done this way because the cleaning of comments catches the // in https:// content = content.replace('#MaterialsFont#', "<link href='https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined' rel='stylesheet'>") content = content.replace('#maxWidth#', ( (tilePreviewWidth.toFloat() * 200).toString()) ) if ( localEndpointState == "Enabled" ) localContent = content if ( localEndpointState == "Disabled" ) localContent = "Local Endpoint Disabled" if ( cloudEndpointState == "Enabled" ) cloudContent = content if ( cloudEndpointState == "Disabled" ) cloudContent = "Cloud Endpoint Disabled" localContent = localContent.replace("#URL#", state.localEndpointData ) localContent = localContent.replace("#URL1#", state.localEndpointPoll ) cloudContent = cloudContent.replace("#URL#", state.cloudEndpointData ) cloudContent = cloudContent.replace("#URL1#", state.cloudEndpointPoll ) // Saves a copy of this finalized HTML\CSS\SVG\SCRIPT so that it does not have to be re-calculated. Everything else is done via the loading of JSON data. state.compiledLocal = localContent state.compiledCloud = cloudContent state.compiledSize = state.compiledLocal.size() def now = new Date() state.compiledDataTime = now.format("EEEE, MMMM d, yyyy '@' h:mm a") } catch (Exception exception) { log.error ("Exception is: $exception") } //Assign the device types cacheDeviceInfo() } //Remove any wasted space from the compiled version to shorten load times. def condense(String input) { if (isLogTrace) log.trace("<b>Entering: Condense</b>") def initialSize = input.size() if (isLogDebug) log.debug ("Condense: Original Size: " + initialSize ) //Reduce any groups of 2 or more spaces to a single space input = input.replaceAll(/ {2,}/, " ") if (isLogDebug) log.debug ("Condense: After concurrent spaces removed: " + input.size() + " bytes." ) //Remove any Tabs from the file input = input.replaceAll(/\t/, "") if (isLogDebug) log.debug ("Condense: After concurrent tabs removed: " + input.size() + " bytes." ) //Replace "; " with ";" input = input.replaceAll("; ", ";") input = input.replaceAll(": ", ":") input = input.replaceAll("> <", "><" ) input = input.replaceAll(" = ", "=" ) //Remove any leading spaces on a line input = input.replaceAll(/(?m)^\s+/, "") //Replace any comments in the HTML\CSS\SVG section that will be between <!-- and --> input = input.replaceAll(/<!--.*?-->/, "") if (isLogDebug) log.debug ("Condense: After HTML\\CSS\\SVG comments removed: " + input.size() + " bytes." ) input = input.replaceAll(/\/\/[^\n\r]*/, "") //if (isLogDebug) log.debug ("Condense: After all comments with leading \\\\ are removed: " + input.size() + " bytes." ) //Replace any comments in the SCRIPT section that will be between \* and *\ Note: Comments beginning with \\ will not be removed. input = input.replaceAll(/(?s)\/\*.*?\*\//, "") if (isLogDebug) log.debug ("Condense: After SCRIPT comments removed: " + input.size() + " bytes." ) if (isLogDebug) log.debug ("Condense: Before: " + String.format("%,d", initialSize) + " - After: " + String.format("%,d", input.size()) + " bytes.") return input } //Determines the deviceInfo (type) for each device and saves it in state along with the deviceID and the short device name as these can only change at compile time. //This function only runs at compile() so it reduces the amount of work being performed by the Hub by caching these results vs calculating them each time. def cacheDeviceInfo(){ def myDeviceList = [] myDevices.each { device -> def deviceInfo = new LinkedHashMap() deviceID = device.getId() deviceInfo.put("ID", deviceID) deviceInfo.put("name", getShortName(device.displayName)) //Check if the device has "Switch" capability. Attributes are switch - ENUM ["on", "off"] if (device.hasCapability("Switch")) { deviceInfo.put("type", 1) } // Check if the device has "SwitchLevel" capability. Attributes are: level - NUMBER, unit:% if (device.hasCapability("SwitchLevel")) { deviceInfo.put("type", 2) } //If the device has ColorTemperature but NOT ColorControl then it is a CT only device. Attributes are: colorName - STRING colorTemperature - NUMBER, unit:°K if ( device.hasCapability("ColorTemperature") && !device.hasCapability("ColorControl") ) { deviceInfo.put("type", 3) } //If the device has ColorControl but NOT ColorTemperature then it is an RGB device. Attributes are: RGB - STRING color - STRING colorName - STRING hue - NUMBER saturation - NUMBER, unit:% if (device.hasCapability("ColorControl") && !device.hasCapability("ColorTemperature") ) { deviceInfo.put("type", 4) } //If the device has ColorControl AND ColorTemperature then it is a RGBW device. Attributes are: RGB - STRING color - STRING colorName - STRING hue - NUMBER saturation - NUMBER, unit:% +++++ colorTemperature - NUMBER, unit:°K if ( device.hasCapability("ColorControl") && device.hasCapability("ColorTemperature") ) { deviceInfo.put("type", 5) } //Check for valves - ENUM ["open", "closed"] if ( device.hasCapability("Valve") ) { deviceInfo.put("type", 10) } //Check for locks - states for lock are: ENUM ["locked", "unlocked with timeout", "unlocked", "unknown"] //Only locked and unlocked are implemented. if ( device.hasCapability("Lock") ) { deviceInfo.put("type", 11) } //Check for Fans - States for speed are: ENUM ["low","medium-low","medium","medium-high","high","on","off","auto"] if ( device.hasCapability("FanControl") ) { deviceInfo.put("type", 12) } //Check for Garage Doors - States are: ENUM ["unknown", "open", "closing", "closed", "opening"] if ( device.hasCapability("GarageDoorControl") || device.hasCapability("DoorControl")) { deviceInfo.put("type", 13) } // Check for Shades and exclude blinds - States for windowShade are: ENUM ["opening", "partially open", "closed", "open", "closing", "unknown"] if (device.hasCapability("WindowShade") && !device.hasCapability("WindowBlind")) { deviceInfo.put("type", 14) } // Check for Blinds - States for windowBlind are: ENUM ["opening", "partially open", "closed", "open", "closing", "unknown"] if (device.hasCapability("WindowBlind")) { deviceInfo.put("type", 15) } //Check for Audio Volume - States for Mute are: ENUM ["unmuted", "muted"] if ( device.hasCapability("AudioVolume") ) { deviceInfo.put("type", 16) } myDeviceList << deviceInfo } //Now go through each of the sensor lists and get the Name, deviceID and type and put it into the deviceInfo def mySensorMap = [ myContacts: 31, myTemps: 32, myLeaks: 33] mySensorMap.each { sensorKey, type -> def deviceList = this."$sensorKey" // Dynamically get the list by its name deviceList.each { device -> def deviceInfo = new LinkedHashMap() def deviceID = device.getId() deviceInfo.put("ID", deviceID) deviceInfo.put("name", getShortName(device.displayName)) deviceInfo.put("type", type) myDeviceList << deviceInfo } } // Add device data to the list state.deviceList = myDeviceList //log.info ( "deviceList is: $deviceList") } //Perform any Device Renaming requested using the Device Name Modification Fields def getShortName(myDevice){ //log.info("Receiving Name: $myDevice") def shortName = myDevice //Handle any null values. if (myReplaceText1 == null || myReplaceText1 == "?" ) myReplaceText1 = "" if (myReplaceText2 == null || myReplaceText2 == "?" ) myReplaceText2 = "" if (myReplaceText3 == null || myReplaceText3 == "?" ) myReplaceText3 = "" if (myReplaceText4 == null || myReplaceText4 == "?" ) myReplaceText4 = "" if (myReplaceText5 == null || myReplaceText5 == "?" ) myReplaceText5 = "" //Replaces any undesireable characters in the devicename - Case Sensitive if (mySearchText1 != null && mySearchText1 != "?") shortName = shortName.replace(mySearchText1, myReplaceText1) if (mySearchText2 != null && mySearchText2 != "?") shortName = shortName.replace(mySearchText2, myReplaceText2) if (mySearchText3 != null && mySearchText3 != "?") shortName = shortName.replace(mySearchText3, myReplaceText3) if (mySearchText4 != null && mySearchText4 != "?") shortName = shortName.replace(mySearchText4, myReplaceText4) if (mySearchText5 != null && mySearchText5 != "?") shortName = shortName.replace(mySearchText5, myReplaceText5) //log.info("returning shortName: $shortName") return shortName } //******************************************************************************************************************************************************************************************* //************** //************** End of Compile Functions //************** //******************************************************************************************************************************************************************************************* //******************************************************************************************************************************************************************************************* //************** //************** Endpoint Activity Handling //************** //******************************************************************************************************************************************************************************************* //This delivers the applet content. def showApplet() { if (isLogTrace) log.trace("<b>Entering: showApplet</b>") def isLocal = false def isCloud = false //Make sure the def host = request.headers?.Host?.first() def protocol = request.headers?.'X-hubitat-scheme'?.first() if(request.requestSource == "local") { isLocal = true ; if (isLogConnections) log.info("Connection: $request.requestSource with protocol $protocol and host $host.") } if(request.requestSource == "cloud") { isCloud = true ; if (isLogConnections) log.info("Connection: $request.requestSource and host $host.") } if ( localEndpointState == "Disabled" && isLocal == true) { result = render contentType: "text/html;charset=UTF-8", data:disabledEndpointHTML(), status:200 return result } if ( cloudEndpointState == "Disabled" && isCloud == true) { result = render contentType: "text/html;charset=UTF-8", data:disabledEndpointHTML(), status:200 return result } //If it gets this far the interface must be enabled. if( isLocal ) { result = render contentType: "text/html;charset=UTF-8", data:state.compiledLocal, status:200 } if( isCloud ) { result = render contentType: "text/html;charset=UTF-8", data:state.compiledCloud, status:200 } return result } //Deliver and Endpoint Disabled Message def disabledEndpointHTML(){ if (isLogTrace) log.trace("<b>Entering: disabledEndpointHTML</b>") myHTML = '''<body style="display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; font-family: Arial, sans-serif; background-color: #f0f0f0;"> <div style="text-align: center; font-size: 24px; line-height: 1.5;color:red;">Endpoint<br>Disabled</div></body>''' return myHTML } // Allows the client to send the changed Device List and JSON to the Hub. types are 1: switch, 2: switchLevel (dimmer), 3: colorTemperature, 4: colorControl - HSL, 5: colorControl - HSV def toHub() { def sessionID = params.sessionID if (isLogTrace) log.trace ("<b>Entering toHub:</b> Session ID: $sessionID") // Extract the body field def bodyJson = request.body if (isLogDeviceInfo) log.info ("<b>Uploading device data via toHub():</b> ${bodyJson}") //Get the latest JSON for the most up to date data getJSON() // Parse JSON def slurper = new JsonSlurper() def group1 = slurper.parseText(state.JSON) // Original state def group2 = slurper.parseText(bodyJson) // New state if (isLogDebug) log.debug ("state.JSON is: $group1") if (isLogDebug) log.debug ("Device Data is: $group2") // Map the second group by 'ID' for easier comparison def group2Map = group2.collectEntries { [(it.ID): it] } // Find changes def changes = [] // Compare devices using ID as the constant group1.each { item1 -> def item2 = group2Map[item1.ID] if (item2) { // If a matching ID is found def diff = [:] // Compare each key except for the ID and type (which are constants) item1.each { key, value -> // Only compare if the key exists in both group1 and group2 if (item2.containsKey(key) && key != "ID" && key != "type") { if (key == "CT") { // Calculate the percentage difference def oldTemp = item1.CT ?: 0 def newTemp = item2.CT ?: 0 def tempDifference = Math.abs(oldTemp - newTemp) // Only consider it a change if the difference is greater than 50 Kelvin. Sometimes the values on the returned controls vary by 1-5 Kelvin for no known reason. if (tempDifference > 50) { diff[key] = [oldTemp, newTemp] } } else if (item2[key] != value) { // For all other keys, check if there's a difference diff[key] = [item1[key], item2[key]] // Capture both old and new values } } } // If there are differences, add them to the changes array if (!diff.isEmpty()) { changes << [ID: item1.ID, type: item1.type, changes: diff] // Include ID and changes } } } // Print changes for each device one at a time changes.each { change -> def myDeviceName = findDeviceById(change.ID) //log.info ("My deviceName is: $myDeviceName") def key = change.changes.keySet().first() // Get the key of the change (since there's only one) def values = change.changes[key] // Get the old and new values if (isLogActions) log.info dodgerBlue("<b>Action: ${myDeviceName} (ID: ${change.ID}): $key: ${values[0]} ---> ${values[1]} </b>") } // Apply all the changes to the devices. applyChangesToDevices(changes) result = render contentType: "application/json", data: "OK", status: 200 return result } //Allows the client to get the Device List and JSON info from the Hub def fromHub(){ def sessionID = params.sessionID if (isLogTrace) log.trace ("<b>Entering fromHUB:</b> Session ID: $sessionID") //Get the latest JSON values. getJSON() //Update the session list to show that this session has downloaded the latest info. if (isLogDebug) log.debug("fromHub: Adding sessionID to updatedSessionList") if (sessionID != null) state.updatedSessionList << sessionID if (isLogDeviceInfo) log.info("<b>Downloading device data via fromHub():</b> $state.JSON") result = render contentType: "application/json", data: state.JSON, status: 200 return result } //Receives a poll request from a session and def poll() { def sessionID = params.sessionID def result if (isLogTrace) log.trace("<b>Entering poll():</b> Session ID: $sessionID") if (!state.updatedSessionList.contains(sessionID)) { // If the list does NOT contain the sessionID then an update is needed. state.updatedSessionList << sessionID result = JsonOutput.toJson([update: true]) } else { // If the list does contain the sessionID then no update is needed. result = JsonOutput.toJson([update: false]) } result = render(contentType: "application/json", data: result, status: 200) return result } //******************************************************************************************************************************************************************************************* //************** //************** End of Endpoint Activity Handling //************** //******************************************************************************************************************************************************************************************* //******************************************************************************************************************************************************************************************* //************** //************** Screen UI and Management Functions //************** //******************************************************************************************************************************************************************************************* //This is the standard button handler that receives the click of any button control. def appButtonHandler(btn) { if (isLogTrace) log.trace("<b>Entering: appButtonHandler: Clicked on button: $btn</b>") switch (btn) { case "rebuildEndpoints": createAccessToken() state.localEndpoint = "${getFullLocalApiServerUrl()}/tb?access_token=${state.accessToken}" state.cloudEndpoint = "${getFullApiServerUrl()}/tb?access_token=${state.accessToken}" state.localEndpointData = "${getFullLocalApiServerUrl()}/tb/data?access_token=${state.accessToken}" state.cloudEndpointData = "${getFullApiServerUrl()}/tb/data?access_token=${state.accessToken}" state.localEndpointPoll = "${getFullLocalApiServerUrl()}/tb/poll?access_token=${state.accessToken}" state.cloudEndpointPoll = "${getFullApiServerUrl()}/tb/poll?access_token=${state.accessToken}" if (isLogDebug) log.debug ("Endpoints have been rebuilt") compile() break case "Compile": compile() break case "btnHideControls": state.hidden.Controls = state.hidden.Controls ? false : true break case "btnHideSensors": state.hidden.Sensors = state.hidden.Sensors ? false : true break case "btnHideEndpoints": state.hidden.Endpoints = state.hidden.Endpoints ? false : true break case "btnHidePolling": state.hidden.Polling = state.hidden.Polling ? false : true break case "btnHideDesign": state.hidden.Design = state.hidden.Design ? false : true break case "btnHidePublish": state.hidden.Publish = state.hidden.Publish ? false : true break case "publishSubscribe": publishSubscribe() break case "unsubscribe": deleteSubscription() break } } //Returns a formatted title for a section header based on whether the section is visible or not. def getSectionTitle(section) { if (section == "Introduction") { if (state.hidden.Intro == true) return sectionTitle("Introduction ▶") else return sectionTitle("Introduction ▼") } if (section == "Controls") { if (state.hidden.Controls == true) return sectionTitle("Controls ▶") else return sectionTitle("Controls ▼") } if (section == "Sensors") { if (state.hidden.Sensors == true) return sectionTitle("Sensors ▶") else return sectionTitle("Sensors ▼") } if (section == "Endpoints") { if (state.hidden.Endpoints == true) return sectionTitle("Endpoints ▶") else return sectionTitle("Endpoints ▼") } if (section == "Polling") { if (state.hidden.Polling == true) return sectionTitle("Polling ▶") else return sectionTitle("Polling ▼") } if (section == "Design") { if (state.hidden.Design == true) return sectionTitle("Design SmartGrid ▶") else return sectionTitle("Design SmartGrid ▼") } if (section == "Publish") { if (state.hidden.Publish == true) return sectionTitle("Publish Remote ▶") else return sectionTitle("Publish Remote ▼") } } String buttonLink(String btnName, String linkText, int buttonNumber) { //if (isLogTrace) log.trace("<b>buttonLink: Entering with $btnName $linkText $buttonNumber</b>") def myColor, myText Integer myFont = 16 if (buttonNumber == settings.activeButton) myColor = "#00FF00" else myColor = "#000000" if (buttonNumber == settings.activeButton) myText = "<b><u>${linkText}</u></b>" else myText = "<b>${linkText}</b>" return "<div class='form-group'><input type='hidden' name='${btnName}.type' value='button'></div><div><div class='submitOnChange' onclick='buttonClick(this)' style='color:${myColor};cursor:pointer;font-size:${myFont}px'>${myText}</div></div><input type='hidden' name='settings[$btnName]' value=''>" } //******************************************************************************************************************************************************************************************* //************** //************** End Screen UI and Management Functions //************** //******************************************************************************************************************************************************************************************* //******************************************************************************************************************************************************************************************* //************** //************** Publishing Functions //************** //******************************************************************************************************************************************************************************************* //This function removes all existing subscriptions for this app and replaces them with new ones corresponding to the devices and attributes being monitored. void publishSubscribe() { if (isLogTrace) log.trace("<b>Entering: publishSubscribe</b>") if (isLogPublish) log.info("<b>Creating subscriptions for Tile: $myRemote with description: $myRemoteName.</b>") //Remove all existing subscriptions unsubscribe() // List of attributes you want to subscribe to def attributesToSubscribe = ["switch", "hue", "saturation", "level", "colorTemperature","valve","lock","speed","door","windowShade","position", "tilt", "mute","volume","contact","water"] deleteSubscription() // Configure subscriptions myDevices?.each { device -> attributesToSubscribe.each { attribute -> if (device.hasAttribute(attribute)) { subscribe(device, attribute, handler) } } } // Configure subscriptions myContacts?.each { device -> attributesToSubscribe.each { attribute -> if (device.hasAttribute(attribute)) { subscribe(device, attribute, handler) } } } // Configure subscriptions myTemps?.each { device -> attributesToSubscribe.each { attribute -> if (device.hasAttribute(attribute)) { subscribe(device, attribute, handler) } } } // Configure subscriptions myLeaks?.each { device -> attributesToSubscribe.each { attribute -> if (device.hasAttribute(attribute)) { subscribe(device, attribute, handler) } } } //Now we call the publishRemote routine to push the new information to the device attribute. publishRemote() } //Save the current HTML to the variable. This is the function that is called by the scheduler. void publishRemote(){ if (isLogTrace) log.trace("<b>Entering: publishRemote</b>") //Test whether we can create a cloud Endpoint to see if OAuth is enabled. try { if( !state.accessToken ) createAccessToken() state.cloudEndpoint = getFullApiServerUrl() + "/tb?access_token=" + state.accessToken } catch (Exception e){ if (isLogError) log.error("This app is not OAuth Enabled. Go to: <b>Developer Tools</b> / <b>Apps Code</b> and open the code for this app. Click on <b>OAuth</b> and then <b>Enable OAuth in App</b> and leave it athe default values.") } if (isLogPublish) log.info("publishTable: Remote $myRemote ($myRemoteName) is being refreshed.") myStorageDevice = parent.getStorageDevice() if ( myStorageDevice == null ) { if (isLogError) log.error("publishTable: myStorageDevice is null. Is the device created and available? This error can occur immediately upon hub startup. Nothing published.") return } def tileLink1 = "[!--Generated:" + now() + "--][div style='height:100%; width:100%; scrolling:no; overflow:hidden;'][iframe src=" + state.localEndpoint + " style='height: 100%; width:100%; border: none; scrolling:no; overflow: hidden;'][/iframe][div]" def tileLink2 = "[!--Generated:" + now() + "--][div style='height:100%; width:100%; scrolling:no; overflow:hidden;'][iframe src=" + state.cloudEndpoint + " style='height: 100%; width:100%; border: none; scrolling:no; overflow: hidden;'][/iframe][div]" if (isLogPublish) log.info ("publishRemote: tileLink1 is: $tileLink1") myStorageDevice.createTile(settings.myRemote, tileLink1, tileLink2, settings.myRemoteName) } //This should get executed whenever any of the subscribed devices receive an update to the monitored attribute. Delays will occur if the eventTimeout is > 0 def handler(evt) { if (isLogTrace) log.trace("<b>Entering: handler with $evt</b>") //Handles the initialization of new variables added in code updates. if (state.variablesVersion == null || state.variablesVersion < codeVersion) updateVariables() //Change the flag used by the polling process to indicate a change has been detected. if (isLogDebug) log.debug("fromHub: Changing isPollUpdate to true.") state.isPollUpdate = true state.updatedSessionList = [] if (isLogPublish) log.info("<b>handler: Event received from Device:${evt.device} - Attribute:${evt.name} - Value:${evt.value}</b>") if (eventTimeout.toString() == "Never" ) { if (isLogPublish) log.info("handler: Event processing is disabled because event timeout is set to 'Never'.") } else { //Publish slightly in the future to allow multiple events to be batched together. runInMillis(eventTimeout.toInteger(), publishRemote, [overwrite: true]) if (isLogPublish) log.info("handler: publishRemote called to run in: ${eventTimeout.toInteger()} milliseconds.") } } //Deletes all event subscriptions. void deleteSubscription() { if (isLogTrace) log.trace("<b>Entering: deleteSubscription</b>") if (isLogPublish) log.info("deleteSubscription: Deleted all subscriptions. To verify click on the App ⚙️ Symbol and look for the Event Subscriptions section.") unsubscribe() } //******************************************************************************************************************************************************************************************* //************** //************** End of Publishing Functions //************** //******************************************************************************************************************************************************************************************* //******************************************************************************************************************************************************************************************* //************** //************** Utility Functions //************** //******************************************************************************************************************************************************************************************* //Returns a string containing the var if it is not null. Used for the controls. static String bold2(s, var) { if (var == null) return "<b>$s (N/A)</b>" else return ("<b>$s ($var)</b>") } // Regular expression for validating UUID format def isValidUUID(address) { def uuidPattern = /^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$/ return address ==~ uuidPattern } //Functions to enhance text appearance static String bold(s) { return "<b>$s</b>" } //Set the Section Titles to a consistent style. static def sectionTitle(title) { return "<span style='color:#000; margin-top:1em; font-size:16px; box-shadow: 0px 0px 3px 3px #40b9f2; padding:1px; background:#40b9f2;'><b>${title}</b></span>" } //Set the body text to a consistent style. static String body(myBody) { return "<span style='color:#17202A;text-align:left; margin-top:0em; margin-bottom:0em ; font-size:18px'>" + myBody + "</span> " } //Produce a horizontal line of the specified width static String line(myHeight) { return "<div style='background:#005A9C; height: " + myHeight.toString() + "px; margin-top:0em; margin-bottom:0em ; border: 0;'></div>" } static String dodgerBlue(s) { return '<font color = "DodgerBlue">' + s + '</font>' } static String red(s) { return '<font color = "Red">' + s + '</font>' } // Convert HSV to RGB using the values in the received map. def getHEXfromHSV(hsvMap){ //log.info ("getHEXfromHSV: hsvMap is: $hsvMap") def myRGB = hubitat.helper.ColorUtils.hsvToRGB([hsvMap.hue, hsvMap.saturation, hsvMap.value]) def HEX = hubitat.helper.ColorUtils.rgbToHEX(myRGB) //log.info ("New HEX Color is: ${HEX}") return HEX } //Receives a 6 digit hex color and an opacity and converts them to HEX8 def convertToHex8(String hexColor, float opacity) { if (isLogTrace) log.trace("<b>Entering convertToHex8: With $hexColor $opacity</b>") if (hexColor != null) hexColor = hexColor.replace("#", "") // Ensure opacity is within the range 0 to 1 opacity = Math.max(0, Math.min(1, opacity)) // Convert the Hex color to HEX8 format def red = Integer.parseInt(hexColor.substring(0, 2), 16) def green = Integer.parseInt(hexColor.substring(2, 4), 16) def blue = Integer.parseInt(hexColor.substring(4, 6), 16) def alpha = Math.round(opacity * 255).toInteger() // Format the values as a hex string def Hex8 = String.format("#%02X%02X%02X%02X", red, green, blue, alpha) return Hex8 } // Iterate over each device in myDevices and check if ID matches. If it does match return that device. def findDeviceById(ID) { def foundDevice = null // Variable to store the found device myDevices.each { device -> if (device.getId() == ID) { foundDevice = device return foundDevice } } return foundDevice } //Convert [HTML] tags to <HTML> for display. def toHTML(HTML) { if (HTML == null) return "" myHTML = HTML.replace("[", "<") myHTML = myHTML.replace("]", ">") return myHTML } //Set the notes to a consistent style. static String summary(myTitle, myText) { myTitle = dodgerBlue(myTitle) return "<details><summary>" + myTitle + "</summary>" + myText + "</details>" } //******************************************************************************************************************************************************************************************* //************** //************** End Utility Functions //************** //******************************************************************************************************************************************************************************************* //******************************************************************************************************************************************************************************************* //************** //************** Standard System Elements //************** //******************************************************************************************************************************************************************************************* //Configures all of the default settings values. This allows us to have some parts of the settings not be visible but still have their values initialized. //We do this to avoid errors that might occur if a particular setting were referenced but had not been initialized. def initialize() { if (state.initialized == true) { if (isLogTrace) log.trace("<b>initialize: Initialize has already been run. Exiting</b>") //return } log.trace("<b>Running Initialize</b>") //Device Selection app.updateSetting("filter", [value: "All Selectable Controls", type: "enum"]) //Endpoints and Polling createAccessToken() state.localEndpoint = "${getFullLocalApiServerUrl()}/tb?access_token=${state.accessToken}" state.cloudEndpoint = "${getFullApiServerUrl()}/tb?access_token=${state.accessToken}" state.localEndpointData = "${getFullLocalApiServerUrl()}/tb/data?access_token=${state.accessToken}" state.cloudEndpointData = "${getFullApiServerUrl()}/tb/data?access_token=${state.accessToken}" state.localEndpointPoll = "${getFullLocalApiServerUrl()}/tb/poll?access_token=${state.accessToken}" state.cloudEndpointPoll = "${getFullApiServerUrl()}/tb/poll?access_token=${state.accessToken}" app.updateSetting("isPollingEnabled", [value: "Enabled", type: "enum"]) app.updateSetting("pollInterval", "3") app.updateSetting("pollUpdateColorSuccess", [value: "#00FF00", type: "color"]) app.updateSetting("pollUpdateColorFail", [value: "#FF0000", type: "color"]) app.updateSetting("pollUpdateWidth", [value: "3", type: "enum"]) app.updateSetting("pollUpdateDuration", [value: "2", type: "enum"]) app.updateSetting("shuttleColor", [value: "#99C5FF", type: "color"]) app.updateSetting("shuttleHeight", [value: "2", type: "enum"]) //Tile Size app.updateSetting("tilePreviewWidth", "3") app.updateSetting("tilePreviewHeight", "2") app.updateSetting("tilePreviewBackground", [value: "#696969", type: "color"]) //Global Settings app.updateSetting("invalidAttribute", [value: "N/A", type: "enum"]) app.updateSetting("defaultDateTimeFormat", 3) app.updateSetting("defaultDurationFormat", 21) //Table Properties app.updateSetting("controlSize", [value: "15", type: "enum"]) app.updateSetting("thp", "5") app.updateSetting("tvp", "3") app.updateSetting("ha", [value: "Stretch", type: "enum"]) app.updateSetting("va", [value: "Center", type: "enum"]) //General Properties app.updateSetting("tempUnits", [value: "°F", type: "enum"]) app.updateSetting("sortHeaderHintAZ", [value: "#00FF00", type: "color"]) app.updateSetting("sortHeaderHintZA", [value: "#FF0000", type: "color"]) app.updateSetting("tempDecimalPlaces", [value: "0 Decimal Places", type: "enum"]) //Column Properties app.updateSetting("column2Header", "Icon") app.updateSetting("column3Header", "Name") app.updateSetting("column4Header", "State") app.updateSetting("column5Header", "Control A/B") app.updateSetting("column6Header", "Control C") app.updateSetting("column7Header", "Last Active") app.updateSetting("column8Header", "Duration") app.updateSetting("column9Header", "Room") app.updateSetting("column10Header", "Pin") //Hidden Columns app.updateSetting("hideColumn1", false) app.updateSetting("hideColumn2", false) app.updateSetting("hideColumn3", false) app.updateSetting("hideColumn4", false) app.updateSetting("hideColumn5", false) app.updateSetting("hideColumn6", false) app.updateSetting("hideColumn7", true) app.updateSetting("hideColumn8", true) app.updateSetting("hideColumn9", true) app.updateSetting("hideColumn10", true) //Info Column Properties app.updateSetting("info1Source", "lastActive") app.updateSetting("its1", "80") app.updateSetting("ita1", "Center") app.updateSetting("info2Source", "lastActiveDuration") app.updateSetting("its2", "80") app.updateSetting("ita2", "Center") app.updateSetting("info3Source", "roomName") app.updateSetting("its3", "80") app.updateSetting("ita3", "Center") //Title Properties app.updateSetting("tt", "?") app.updateSetting("ts", "125") app.updateSetting("tp", "5") app.updateSetting("tc", [value: "#000000", type: "color"]) app.updateSetting("tb", [value: "#8FC126", type: "color"]) app.updateSetting("ta", "Center") app.updateSetting("to", "1") //Header Properties app.updateSetting("hts", "100") app.updateSetting("htc", [value: "#000000", type: "color"]) app.updateSetting("hbc", [value: "#8FC126", type: "color"]) app.updateSetting("hbo", "1") //Row Properties app.updateSetting("rts", "90") app.updateSetting("rtc", [value: "#000000", type: "color"]) app.updateSetting("rbc", [value: "#D9ECB1", type: "color"]) app.updateSetting("rbo", "1") app.updateSetting("highlightSelectedRows", "True") app.updateSetting("rbs", [value: "#FFE18F", type: "color"]) app.updateSetting("highlightPinnedRows", "True") app.updateSetting("rbpc", [value: "#A7C7FB", type: "color"]) //Publishing app.updateSetting("mySelectedRemote", "") app.updateSetting("publishEndpoints", [value: "Local", type: "enum"]) app.updateSetting("eventTimeout", "2000") //Set initial Log settings app.updateSetting('isLogConnections', false) app.updateSetting('isLogActions', true) app.updateSetting('isLogPublish', false) app.updateSetting('isLogDeviceInfo', false) app.updateSetting('isLogError', true) app.updateSetting('isLogDebug', false) app.updateSetting('isLogTrace', false) //Have all the sections collapsed to begin with except devices state.hidden = [Controls: false, Sensors: true, Endpoints: true, Polling: true, Design: false, Publish: false] state.updatedSessionList = [] state.initialized = true state.compiledLocal = "<span style='font-size:32px;color:yellow'>No Devices!</span>" state.compiledCloud = "<span style='font-size:32px;color:yellow'>No Devices!</span>" } def updated(){ if(!state?.isInstalled) { state?.isInstalled = true } } //******************************************************************************************************************************************************************************************* //************** //************** End Standard System Elements //************** //******************************************************************************************************************************************************************************************* //******************************************************************************************************************************************************************************************* //************** //************** Remote Control APPlet Code //************** //******************************************************************************************************************************************************************************************* //This contains the whole HTML\CSS\SVG\SCRIPT file equivalent. Placing it in a function makes it easy to collapse. static def myHTML() { def HTML = ''' <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>🪄#BrowserTitle#</title> //This is a placeholder for the link to the Google Materials Font. In its normal state it gets cleaned up with the comments because of the presence of the //. #MaterialsFont# <style> :root {--control: #controlSize#px; --tickMarks : repeating-linear-gradient(to right, transparent 0%, transparent 4%, black 5%, transparent 6%, transparent 9%); //--blinds : repeating-linear-gradient(to right, transparent 0%, transparent 2%, black 2%, #ccc 4%, transparent 4%, transparent 6%, black 6%, #ccc 8%, transparent 8%), linear-gradient( to left, #FFF 0%, #FFF 30%, #111 100%); --blinds : repeating-linear-gradient(to right, black 0%, #ccc 3%, black 3%, #ccc 6%, black 6%, #ccc 9%); --shades : linear-gradient( 3deg, #000 0%, #333 45%, #CCC 55%, #FFF 100%); --dimmer : linear-gradient(to right, #000 0%, #333 15%, #666 30%, #888 45%, #AAA 60%, #DDD 75%, #FFF 100% ); --CT : linear-gradient(to right, #FF4500 0%, #FFA500 16%, #FFD700 33%, #FFFACD 49%, #FFFFE0 60%, #F5F5F5 66%, #FFF 80%, #ADD8E6 100% ); } html, body { display:flex; flex-direction:column; align-items:#ha#; height:99%; margin:5px; font-family:'Arial', 'Helvetica', sans-serif; cursor:auto;flex-grow: 1; overflow:auto;box-sizing: border-box;} .container { width: 99%; max-width:#maxWidth#px; margin: 10px auto; padding: 3px;} /* Mobile Styles - For screens 1024px or smaller. */ @media (max-width: 768px) { .container {max-width:95%; margin-bottom:20px;} html, body {justify-content: flex-start; align-items: stretch; height:auto; overflow:auto;} } /* Eliminates scrollbars within the dashboard for Webkit browsers. For Firefox scrollbar-width: none; eliminates scrollbars within the Dashboard */ //::-webkit-scrollbar {display: none;} /* Suppress the defaults for sliders on each browser platform */ input[type=range] { -webkit-appearance: none; width: 100%; background: transparent; } /* Special styling for WebKit/Blink */ input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; height: var(--control); width: var(--control); border-radius: calc(var(--control) / 2); background: #1E90FF; cursor: pointer; vertical-align:middle;} /* Apply an outline when the slider is focused (clicked or selected) */ input[type=range]:focus {outline: 2px solid #FF4500; } input[type=range]::-ms-track { width: 100%; cursor: pointer; background: transparent; border-color: transparent; color: transparent;} /* All the same stuff for Firefox and IE */ input[type=range]::-moz-range-thumb, input[type=range]::-ms-thumb { height: 36px; width: 16px; border-radius: 3px; background: #1E90FF; cursor: pointer;} /* START OF TITLE CLASSES */ .title{ padding: #tp#px; text-align: #ta#; font-size: #ts#%; font-weight: 400; color: #tc#; background-color: #tb#; display: #titleDisplay#;} /* START OF TABLE CLASSES */ table {width: 100%; border-collapse: collapse; table-layout: auto; } th, td { padding: calc(#tvp#px + 3px) #thp#px; text-align:center; vertical-align:middle; border:1px solid black; transition:background-color 0.3s; user-select:none;} th { background-color: #hbc#; font-weight: bold; font-size: #hts#%; color: #htc#; margin:1px; } .ascSort { background: linear-gradient(to bottom, #hbc# 0%, #hbc# 95%, #sortHeaderHintAZ# 100%); text-decoration: underline;} .descSort { background : linear-gradient(to bottom, #hbc# 0%, #hbc# 95%, #sortHeaderHintZA# 100%); text-decoration: underline;} tr { background-color: #rbc#;} tr:hover { background-color: #rbs#; } .selected-row { background-color: #rbs#;} .pinned-row {background-color: #rbpc#;} /* Widths of columns 1, 2 & 10 are derived from the width of the control element. Columns 5 and 6 are fixed as set by the user. The remaining columns with be auto-calculated to best fit the text. */ th:nth-child(1), td:nth-child(1) { width:calc(var(--control) * 1.5); display:#hideColumn1#; } th:nth-child(2), td:nth-child(2) { width:calc(var(--control) * 2.5); display:#hideColumn2#; } th:nth-child(3) {padding-left:calc(#thp#px + 5px); text-align:left;}, td:nth-child(3) {text-align:left; padding-left:calc(#thp#px); } th:nth-child(4), td:nth-child(4) { width:calc(var(--control) * 3); display:#hideColumn4#; } th:nth-child(5), td:nth-child(5) { width:#column5Width#px; display:#hideColumn5#; } th:nth-child(6), td:nth-child(6) { width:#column6Width#px; display:#hideColumn6#; } th:nth-child(7), td:nth-child(7) { display:#hideColumn7#; } th:nth-child(8), td:nth-child(8) { display:#hideColumn8#; } th:nth-child(9), td:nth-child(9) { display:#hideColumn9#; } th:nth-child(10), td:nth-child(10) { width:calc(var(--control) * 2); display:#hideColumn10#; } /* START OF CONTROLS CLASSES */ /* Column 1 Checkboxes */ input[type="checkbox"] {height:var(--control); width:var(--control); margin:0px; margin-top:3px; cursor: pointer; } /* Column 2 - Materials Symbols - Icons */ .material-symbols-outlined {padding:3px; border-radius: 50%; font-size:calc(var(--control) * 1.5 )} .material-symbols-outlined.on { background-color:rgba(255,255,0, 0.3); color: #333333;} .material-symbols-outlined.off {color: #AAA;} .open { background-color:rgba(255,213,128, 0.7); color:#333333;} .warn { background-color:rgba(255,0,0, 0.7); color:#333333;} .good { background-color:rgba(0,255,0, 0.7); color:#333333;} /* Column 3 Device Names */ .editable-input {border: none; width: 95%; background: transparent; font-size: #rts#%; color: #rtc#;} /* Column 4 On/Off Switch */ .toggle-switch { position: relative; display: inline-block; vertical-align: middle; margin-top: calc(var(--control) / 3); margin-bottom: calc(var(--control) / 5 ); width: calc(var(--control) * 2); height: var(--control); background-color: #CCC; cursor: pointer; border-radius: calc(var(--control) / 2); transition: background-color 0.3s ease; box-shadow: 0 0 calc(var(--control) / 1.5) 0px rgba(255, 99, 71, 1); } .toggle-switch::before { content: ''; position: absolute; width: calc(var(--control) * 0.87); height: calc(var(--control) * 0.87); border-radius: 50%; background-color: white; top: calc(var(--control) * 0.066); left: calc(var(--control) * 0.066); transition: transform 0.3s ease; } .toggle-switch.on { background-color: #2196F3; box-shadow: 0 0 calc(var(--control) / 1.5) calc(var(--control) / 5) rgba(255, 255, 0, 1); } .toggle-switch.on::before { transform: translateX(calc(var(--control))); } /* Column 5 - Control Group 1 - Level and Kelvin Sliders */ .control-container {display:flex;position: relative; width: 95%; display: flex; justify-content: center; align-items: center; background-color:#rbc#; margin:auto; } .CT-slider, .level-slider, .blinds-slider, .shades-slider, .volume-slider, .tilt-slider { width: 90%; opacity:0.75; border-radius:0px; height:var(--control); outline: 2px solid #888; cursor: pointer;} .CT-value, .level-value, .blinds-value, .shades-value, .volume-value, .tilt-value, .state-value {position:absolute; top:50%; transform:translateY(-50%); font-size:#rts#%; pointer-events:none; text-align:center; cursor:pointer; font-weight:bold; background:#fff8; padding:0px;color: #rtc#;} .state-text {font-size:#rts#%; pointer-events:none; cursor:pointer; color: #rtc#;} /* Custom properties for WebKit-based browsers (Chrome, Safari) */ .CT-slider::-webkit-slider-runnable-track { background: var(--CT); height: 100% } .level-slider::-webkit-slider-runnable-track { background: var(--dimmer); height: 100%} .blinds-slider::-webkit-slider-runnable-track { background: var(--blinds); height: 100%; background-size: 100% 100%, 100% 100%; border: 0px solid #DDD; } .shades-slider::-webkit-slider-runnable-track { background: var(--shades); height: 100%; background-size: 100% 100%, 100% 100%; border: 0px solid #DDD; } //This creates a series of tick marks representing 0 - 100 with a color gradient background from green to red as volume increases. .volume-slider::-webkit-slider-runnable-track { background: var(--tickMarks), linear-gradient(to right, green, orange, red); height: 100%; background-size: 100% 100%; background-repeat: no-repeat; border: 0px solid #DDD;} .tilt-slider::-webkit-slider-runnable-track { background: var(--tickMarks),linear-gradient(to right, #000 0%, #333 10%, #0D47A1 30%, #17D 40%, #4682B4 50%, #8CF 65%, #FFF 85%, #FFF 100%); height: 100%; background-size: 100% 100%, 100% 100%; border: 0px solid #DDD; } .level-slider::-webkit-slider-thumb, .CT-slider::-webkit-slider-thumb, .shades-slider::-webkit-slider-thumb, .blinds-slider::-webkit-slider-thumb, .volume-slider::-webkit-slider-thumb { background: dodgerblue; border-radius: 50%; cursor: pointer; } .tilt-indicator {font-size: var(--control); line-height: var(--control); transform-origin: center center; transition: transform 0.1s ease-in-out;} /* Add Mozilla Browser Support - Untested */ .CT-slider::-moz-range-track { background: var(--CT); height: 100%; } .level-slider::-moz-range-track { background: var(--dimmer); height: 100%; } .blinds-slider::-moz-range-track { background: var(--blinds); height: 100%; background-size: 100% 100%, 100% 100%; border: 0px solid #DDD; } .shades-slider::-moz-range-track { background: var(--shades); height: 100%; background-size: 100% 100%, 100% 100%; border: 0px solid #DDD; } .volume-slider::-moz-range-track { background: var(--tickMarks), linear-gradient(to right, green, orange, red); height: 100%; background-size: 100% 100%; background-repeat: no-repeat; border: 0px solid #DDD; } .tilt-slider::-moz-range-track { background: var(--tickMarks), linear-gradient(to right, #000 0%, #333 10%, #0D47A1 30%, #17D 40%, #4682B4 50%, #8CF 65%, #FFF 85%, #FFF 100%); height: 100%; background-size: 100% 100%, 100% 100%; border: 0px solid #DDD; } .level-slider::-moz-range-thumb, .CT-slider::-moz-range-thumb, .shades-slider::-moz-range-thumb, .blinds-slider::-moz-range-thumb, .volume-slider::-moz-range-thumb { background: dodgerblue; border-radius: 50%; cursor: pointer;} /* Column 6 - Control Group 2 - Color */ input[type="color"]::-webkit-color-swatch-wrapper { padding: 0px; } .colorPicker{border: 2px solid #888; border-radius: 2px; width: calc(var(--control) * 3); height: calc( var(--control) * 1.25); cursor: pointer;} /* Column 6 Color Control */ /* Info Columns 7 & 8 & 9 */ .info1, .info2, .info3 { border:none; background:transparent; white-space:nowrap; color:#rtc#;} .info1 { font-size: #its1#%; text-align: #ita1#; } .info2 { font-size: #its2#%; text-align: #ita2#; } .info3 { font-size: #its3#%; text-align: #ita3#; } /* Define glow effects */ .glow-EffectSuccess {outline: #pollUpdateWidth#px solid #pollUpdateColorSuccess#;} .glow-EffectFail {outline: #pollUpdateWidth#px solid #pollUpdateColorFail#;} .glow-EffectCT {outline: 2px solid #1E90FF;} .glow-EffectRGB {border: 2px solid #1E90FF;} /* Refresh bar styling */ #shuttle { display:none; position:relative; height:#shuttleHeight#px; width:5%; background-color:#shuttleColor#; border-radius:3px; animation:none;} @keyframes slide { 0% { left: 0%; width: 5%; } 50% { left: 95%; width: 5%; } 100% { left: 0%; width: 5%; } } @keyframes slideBackward { 0% { left: 95%; width: 5%; } 100% { left: 0%; width: 5%; } } @keyframes blink { 0% { opacity: 1; } 50% {opacity: 0;} 100% {opacity: 1;} } .blinking { animation: blink 1s infinite; } .button-group { display: flex; justify-content: space-between; align-items: center; margin: 0 auto; text-align: center;} .button { flex:1; max-height: var(--control); padding: 4px 8px; margin: 0 2px; border-radius: var(--control); background-color: #d3d3d3; text-align: center; transition: background-color 0.3s, border-color 0.3s, outline 0.3s; display: flex; align-items: center; justify-content: center; color: #FFF; font-size: calc(2 + var(--control) / 1.5); cursor: pointer; } .button:hover { background-color: #3CB371;} .button:active { background-color: #1C86EE;} .button.selected { background-color: #1E90FF;} .button-group.disabled { opacity: 0.75; pointer-events: none; cursor: not-allowed; } @keyframes spin { from {transform: rotate(0deg);} to {transform: rotate(360deg);} } .spin-low {animation: spin 3s linear infinite;} .spin-medium {animation: spin 1.5s linear infinite;} .spin-high {animation: spin 0.75s linear infinite;} </style> </head> //*********************************************** HTML Block ************************************************************************* //************************************************************************************************************************************** <body> <div class="container"> <div class="title" id="title">#tt#</div> <table id="sortableTable"> <thead> <tr> <th><input type="checkbox" id="masterCheckbox" onclick="toggleAllCheckboxes(this)" onchange="updateHUB()" title="Select All/Deselect All"></th> <th id="icon" class="sortLinks" onclick="sortTable(1, this);" title="Icon - Sort"><span id="iconHeader">Icon</span></th> <th id="nameHeader" class="sortLinks" onclick="sortTable(2, this);"> <div style="display: flex; justify-content: space-between; align-items: center;"> <span title="Sort Name A-Z">#column3Header#</span><span id="refreshIcon" style="font-size: 1.5em; cursor:pointer;" onclick="event.stopPropagation(); refreshPage(50);" title="Refresh Data"><b>↻</b></span></div></th> <th id="stateHeader" class="sortLinks th" onclick="sortTable(3, this);"><span title="Sort State On-Off">State</span></th> <th id="ControlAB" class="sortLinks" onclick="toggleControl()" title="Toggle Control A/B">#column5Header#</th> <th id="color">#column6Header#</th> <th id="i1" class="sortLinks" onclick="sortTable(6, this);" title="Info1 - Alpha Sort"><span id="info1Header">#Info1#</span></th> <th id="i2" class="sortLinks" onclick="sortTable(7, this);" title="Info2 - Alpha Sort"><span id="info2Header">#Info2#</span></th> <th id="i3" class="sortLinks" onclick="sortTable(8, this);" title="Info3 - Alpha Sort"><span id="info3Header">#Info3#</span></th> <th id="pin" title="Pinned Items"><span id="pinHeader">Pin</span></th> </tr> </thead> <tbody><!-- Table rows will be dynamically loaded from JSON --></tbody> </table> <div id="shuttle"> </div> //We have an invisible Null character on the bottom here which forces a gap after the shuttle at the end of the table. </div> <script> // Retrieve values from sessionStorage or initialize to default for the active column and direction. Session storage does no persist across the closing of the browser window. localStorage does persist across the closing of the browser. // Use a prefixed key for localStorage and sessionStorage const storageKey = (key) => `${"#AppID#"}_${key}`; // LocalStorage operations with AppID let storedSortDirection = JSON.parse(localStorage.getItem(storageKey("sortDirection"))); let sortDirection = storedSortDirection || { activeColumn: 2, direction: 'asc' }; let showSlider = (localStorage.getItem(storageKey("showSlider")) === "A" || localStorage.getItem(storageKey("showSlider")) === "B") ? localStorage.getItem(storageKey("showSlider")) : "A"; // SessionStorage operations with AppID sessionStorage.removeItem(storageKey('sessionID')); let sessionID = sessionStorage.getItem(storageKey('sessionID')) || (sessionStorage.setItem(storageKey('sessionID'), (Math.abs(Math.random() * 0x7FFFFFFF | 0)).toString(16).padStart(8, '0')), sessionStorage.getItem(storageKey('sessionID'))); let isLogging = sessionStorage.getItem(storageKey('isLogging')) === 'true' ? true : false; // Force Enable or Disable logging sessionStorage.setItem(storageKey('isLogging'), 'false'); isLogging = false; //Polling Related variables let pollInterval = #pollInterval#; let isPollingEnabled = #isPollingEnabled#; if (isPollingEnabled === true ) { const poller = startPolling('#URL1#', pollResult); //Start the Shuttle animation const shuttle = document.getElementById('shuttle'); shuttle.style.display = 'block'; shuttle.style.animation = `slide ${pollInterval *2}ms ease-in-out infinite`; } //This has to do with transaction handling let transactionTimeout = #commandTimeout#; let transaction = null; //*********************************************** Table Body ************************************************************************* //************************************************************************************************************************************** //Updates the Table with the JSON received from the Hub. function loadTableFromJSON(myJSON) { const tbody = document.querySelector("#sortableTable tbody"); tbody.innerHTML = ""; // Clear existing table rows const savedCheckboxStates = JSON.parse(sessionStorage.getItem(storageKey("checkboxStates"))) || {}; myJSON.forEach((item, index) => { const row = document.createElement('tr'); let stateHTML = ""; //Holds the HTML for the state control column let control1HTML = ""; //Holds HTML for the first controls column let control2HTML = ""; //Holds HTML for the second controls column let pinHTML = ""; //Holds HTML for the Pin column // Data mapping for row dataset const dataMap = { ID: item.ID, type: item.type, speed:item.speed, level:item.level, position:item.position, tilt:item.tilt, volume:item.volume, colorMode:item.colorMode || "None", info1:item.i1, info2:item.i2, info3:item.i3, icon:item.icon, class:item.cl, pin:item.pin }; // Apply each property to the row dataset using a loop and map Object.entries(dataMap).forEach(([key, value]) => { row.dataset[key] = value; }); // Ensure color is defined and in the correct format const colorValue = item.color && /^#[0-9A-F]{6}$/i.test(item.color) ? item.color : "#FFF"; //Configure the Icon text. let iconText = `<i class='material-symbols-outlined ${item.cl}'>${item.icon}</i>`; // Sliders for type <= 5 which is all kinds of bulbs and switches. if (item.type <= 5 ){ control1HTML = `<div class="control-container"> <input type="range" class="level-slider" min="0" max="100" value="${item.level}" style="display: ${showSlider === 'A' && [2, 3, 4, 5].includes(item.type) ? 'block' : 'none'}" oninput="updateSliderValue(this, 'level')" onchange="updateHUB()"> <span class="level-value" style="display: ${showSlider === 'A' ? 'block' : 'none'}">${item.level}%</span> <input type="range" class="CT-slider ${item.colorMode === 'CT' ? 'glow-EffectCT' : ''}" min="2000" max="6500" value="${item.CT}" style="display: ${showSlider === 'B' && [3,5].includes(item.type) ? 'block' : 'none'}" oninput="updateSliderValue(this, 'CT')" onchange="updateHUB()"> <span class="CT-value" style="color:black; display: ${showSlider === 'B' && [3,5].includes(item.type) ? 'block' : 'none'}">${item.CT}°K</span> </div>`; }; // Buttons for Fan (Radio buttons did not re-size properly) if (item.type === 12) { const isDisabled = item.switch === 'off' ? 'disabled' : ''; // Determine if buttons should be disabled const disabledClass = item.switch === 'off' ? 'disabled' : ''; // Add a visual class for the disabled state for the whole radio group control1HTML = `<div class="button-group ${disabledClass}"> <div class="button ${item.speed === 'low' ? 'selected' : ''} ${isDisabled}" data-speed="low" onclick="speed(this); updateHUB(); toggleChecked(this)">L</div> <div class="button ${item.speed === 'medium' ? 'selected' : ''} ${isDisabled}" data-speed="medium" onclick="speed(this); updateHUB(); toggleChecked(this)">M</div> <div class="button ${item.speed === 'high' ? 'selected' : ''} ${isDisabled}" data-speed="high" onclick="speed(this); updateHUB(); toggleChecked(this)">H</div></div> `}; //Shade if (item.type === 14 || item.type === 15){ control1HTML = `<div class="control-container"><input type="range" class="shades-slider" min="0" max="100" value="${item.position}" oninput="updateSliderValue(this, 'position')" onchange="updateHUB()"> <span class="shades-value"><b>${item.position}%</b></span></div>`; }; //Blind if (item.type === 15){ control2HTML = `<div style="display: flex; align-items: center;"> <div class="control-container" style="display: flex; align-items: center;"> <input type="range" class="tilt-slider" min="0" max="90" value="${item.tilt}" oninput="updateSliderValue(this, 'tilt')" onchange="updateHUB()"> <span class="tilt-value"}">${item.tilt}°</span> </div><div id="tilt-indicator" class="tilt-indicator" style="margin-left: 20px; margin-right: 10px; display: inline-block; vertical-align: middle;"> | </div></div>`; }; //Volume if (item.type === 16 ){ control1HTML = `<div class="control-container"><input type="range" class="volume-slider" min="0" max="100" value="${item.volume}" oninput="updateSliderValue(this, 'volume') " onchange="updateHUB()"><span class="volume-value">${item.volume}%</span></div>`; }; // Control 2 - Color Picker if (item.type === 4 || item.type === 5) { control2HTML = `<input type="color" class="colorPicker ${item.colorMode === 'RGB' ? 'glow-EffectRGB' : ''}" id="colorInput${index}" value="${colorValue}" onchange="updateColor(this); updateHUB()">` } //Insert a switch if it is a control type, i.e. < 30 if (item.type <= 30) { stateHTML = `<div class="toggle-switch ${item.switch === 'on' ? 'on' : ''}" data-state="${item.switch}" onclick="toggleSwitch(this); updateHUB()"></div>`;} // For any kind of sensor insert the sensor text if (item.type >= 31) { stateHTML = '<div class="state-text">' + item.switch + '</div>'; } // Pinned Items if( item.pin === "on") { pinHTML = (`<i class="material-symbols-outlined">location_on</i>`); row.className = "pinned-row"; // Apply the class to the row } //No controls other than the switch for some devices; switch, valve, lock and garage door, if (item.type === 1 || item.type === 10 || item.type === 11 || item.type === 13 ) control1HTML = ''; const isChecked = savedCheckboxStates[item.ID] || false; row.innerHTML = ` <td><input type="checkbox" class="option-checkbox" ${isChecked ? 'checked' : ''} onchange="toggleRowSelection(this)"></td> <td>${iconText}</td> <td><input type="text" class="editable-input" value="${item.name}" onchange="updateHUB()" readonly></td> <td>${stateHTML}</td> <td>${control1HTML}</td> <td>${control2HTML}</td> <td><div class="info1">${item.i1}</div></td> <td><div class="info2">${item.i2}</div></td> <td><div class="info3">${item.i3}</div></td> <td>${pinHTML}</td>`; tbody.appendChild(row); }); markColumnHeader(); updateAllTiltIndicators(); } //*********************************************** Data Transfer *********************************************************************** //************************************************************************************************************************************** //Update the Tilt angle on ALL tilt indicators function updateAllTiltIndicators() { // Select all rows in the table then iterate through each row const rows = document.querySelectorAll("#sortableTable tbody tr"); rows.forEach((row, index) => { // Find the tilt slider and tilt indicator in the row const tiltSlider = row.querySelector(".tilt-slider"); const tiltIndicator = row.querySelector(".tilt-indicator"); if (!tiltSlider || !tiltIndicator) return; tiltIndicator.style.transform = `rotate(${-tiltSlider.value}deg)`; // Counterclockwise rotation }); } function updateHUB() { const rows = document.querySelectorAll("#sortableTable tbody tr"); const output = Array.from(rows) .map((row) => { const type = Number(row.dataset.type); // Skip the row if type > 30 as we do not need to report on sensors. if (type > 30) return null; const outputData = { name: row.querySelector('td:nth-child(3) input').value, ID: row.dataset.ID, type }; // Conditionally add fields based on type and lastCommand const addField = (condition, field, value) => condition && (outputData[field] = value); addField(type >= 1 && lastCommand === "switch", "switch", row.querySelector('.toggle-switch').dataset.state); addField((type >= 2 && type <= 5) && lastCommand === "level", "level", parseInt(row.querySelector('.level-slider')?.value || 0)); addField((type === 3 || type === 5) && lastCommand === "CT", "CT", parseInt(row.querySelector('.CT-slider')?.value || 0)); addField((type === 4 || type === 5) && lastCommand === "color", "color", (row.querySelector('input[type="color"]')?.value || '#000000').toUpperCase()); addField(type === 12 && lastCommand === "speed", "speed", row.querySelector('.button.selected') ? row.querySelector('.button.selected').getAttribute('data-speed') : "off"); addField((type === 14 || type === 15 ) && lastCommand === "position", "position", parseInt(row.querySelector('.shades-slider')?.value || 0)); addField((type === 15) && lastCommand === "tilt", "tilt", parseInt(row.querySelector('.tilt-slider')?.value || 0)); addField((type === 16) && lastCommand === "volume", "volume", parseInt(row.querySelector('.volume-slider')?.value || 0)); return outputData; }) .filter(row => row !== null); // Remove null rows from the output array if (isLogging) console.log("Output is:", output); // Send data to the backend sendData(JSON.stringify(output)); } //Sends the Data to the Hub function sendData(payload) { handleTransaction("begin"); const url = '#URL#'; fetch(`${url}&sessionID=${sessionID}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: payload }).catch(error => { console.error('Error:', error); }); } // Function to fetch JSON data from a URL and return it async function fetchData() { if (isLogging) { console.log("fetchData(): Downloading data from Hub.") }; const url = '#URL#'; // Example URL try { const response = await fetch(`${url}&sessionID=${sessionID}`); //Pass the session ID back to the Hub app to track state. const jsonData = await response.json(); // Parse the response as JSON return jsonData; // Return the JSON data } catch (error) { console.error("Error fetching data:", error); return null; // Return null if there's an error } } //*********************************************** onUpdate event handling *********************************************************** //************************************************************************************************************************************** // Fan Speed change - Highlight the selected button function speed(button) { const row = button.closest('tr'); row.querySelectorAll('.button').forEach(btn => btn.classList.remove('selected')); button.classList.add('selected'); lastCommand = "speed"; } // Update the color value based on the input function updateColor(colorInput) { const row = colorInput.closest('tr'); const checkbox = row?.querySelector("input[type='checkbox']"); // Update the current row's color display const colorDisplay = row?.querySelector('.color-display'); // Assuming there's an element to show the color //Set the color of this instance of the control if (colorDisplay) { colorDisplay.style.backgroundColor = colorInput.value; } // Sync the colors for other checked rows to this value if ( checkbox && checkbox.checked) { syncRows("color", colorInput.value); } lastCommand = "color"; // Track the last command } //Update the slider text label showing the selected value for level, position or CT function updateSliderValue(slider, command) { const row = slider?.closest('tr'); const checkbox = row?.querySelector("input[type='checkbox']"); // Check if the slider is of type slider-tilt and update accordingly let valueText = `${slider.value}%`; // Default value for percentage if (slider.classList.contains('tilt-slider')) { valueText = `${slider.value}°`}; // Change the slider value for this row slider.nextElementSibling.innerText = valueText; if (command === "CT") row.dataset.colorMode = "CT"; if (command === "tilt" ) updateAllTiltIndicators(); //Sync the sliders to this value if ( checkbox && checkbox.checked) { //Now change the label for the other selected rows syncRows(command, slider.value); } lastCommand = command; } // Toggle the first control column between Control A and Control B sliders and hide unused sliders function toggleControl() { // Toggle the Control Group showSlider = showSlider === "A" ? "B" : "A"; sessionStorage.setItem(storageKey("showSlider"), showSlider); // Update visibility for sliders and values document.querySelectorAll('tr').forEach(row => { const type = Number(row.dataset.type); // Determine visibility for each slider type const showLevel = showSlider === "A" && !(type <= 1 || type >= 10); const showCT = showSlider === "B" && (type === 3 || type === 4 || type === 5 || type >= 10); // Update visibility for Level sliders and values row.querySelectorAll('.level-slider, .level-value').forEach(el => { el.style.display = showLevel ? 'block' : 'none'; }); // Update visibility for CT sliders and values row.querySelectorAll('.CT-slider, .CT-value').forEach(el => { el.style.display = showCT ? 'block' : 'none'; }); }); } //*********************************************** Table UI Management **************************************************************** //************************************************************************************************************************************** //*********************************************** CheckBox Handling ***************************************************************** //************************************************************************************************************************************** function toggleRowSelection(checkbox) { const row = checkbox.closest('tr'); const ID = row.dataset.ID; const checkboxStates = JSON.parse(sessionStorage.getItem(storageKey("checkboxStates"))) || {}; row.classList.toggle('selected-row', checkbox.checked); if (ID) checkboxStates[ID] = checkbox.checked; sessionStorage.setItem(storageKey("checkboxStates"), JSON.stringify(checkboxStates)); } function toggleAllCheckboxes(masterCheckbox) { document.querySelectorAll('.option-checkbox').forEach(checkbox => { checkbox.checked = masterCheckbox.checked; toggleRowSelection(checkbox); }); } function toggleChecked(label) { // Remove 'checked' class from all labels const labels = document.querySelectorAll('.radio-label'); labels.forEach(lbl => lbl.classList.remove('checked')); // Add 'checked' class to the label label.classList.add('checked'); } //Update the switches function toggleSwitch(switchElement) { //console.log("Toggle command received"); const row = switchElement.closest('tr'); const newState = (switchElement.classList.toggle('on') ? 'on' : 'off'); switchElement.dataset.state = newState; row?.querySelector('.radio-group')?.classList.toggle('disabled', newState === 'off'); row?.querySelectorAll('.radio-group input[type="radio"]').forEach(input => input.disabled = newState === 'off'); if (row?.querySelector("input[type='checkbox']")?.checked) syncRows("switch", newState); lastCommand = "switch"; } //Takes any action initiated by the user and applies it to any other row that has the master checkbox checked. function syncRows(command, value) { //console.log ("Received: ", command); document.querySelectorAll('.option-checkbox:checked').forEach(checkbox => { const row = checkbox.closest('tr'); const updateElement = (selector, value) => { const element = row.querySelector(selector); if (element) element.value = value; }; switch (command) { case "color": updateElement('input[type="color"]', value); row.dataset.colorMode = "RGB"; break; case "level": if (row.querySelector('.level-slider')) { // Ensure the slider exists updateElement('.level-slider', value); row.querySelector('.level-value').innerText = value + '%'; }; break; case "CT": if (row.querySelector('.CT-slider')) { // Ensure the slider exists updateElement('.CT-slider', value); row.querySelector('.CT-value').innerText = value + '°K'; } break; case "tilt": if (row.querySelector('.tilt-slider')) { // Ensure the slider exists updateElement('.tilt-slider', value); row.querySelector('.tilt-value').innerText = value + '%'; }; break; case "position": if (row.querySelector('.shades-slider')) { // Ensure the slider exists updateElement('.shades-slider', value); row.querySelector('.shades-value').innerText = value + '%'; }; if (row.querySelector('.blinds-slider')) { // Ensure the slider exists updateElement('.blinds-slider', value); row.querySelector('.blinds-value').innerText = value + '%'; }; break; case "volume": if (row.querySelector('.volume-slider')) { // Ensure the slider exists updateElement('.volume-slider', value); row.querySelector('.volume-value').innerText = value + '%'; }; break; case "switch": const toggleSwitch = row.querySelector('.toggle-switch'); if (toggleSwitch) { toggleSwitch.classList.toggle('on', value === 'on'); toggleSwitch.dataset.state = value; } break; } }); lastCommand = "checked"; } //*********************************************** Polling and Transactions *********************************************************** //************************************************************************************************************************************** //Starts the polling process of the Hub using the global value of pollInterval which can vary between the minimum and maximum values depending on activity. function startPolling(url, pollResult) { const poller = setInterval(async () => { try { const response = await fetch(`${url}&sessionID=${sessionID}`); if (!response.ok) throw new Error(`Error: ${response.status}`); const data = await response.json(); pollResult(data); if (isLogging) console.log("Session ID:", sessionID); } catch (error) { console.error("Polling error:", error); clearInterval(poller); } }, pollInterval); return poller; } // This is the callback function. When the polling process receives a response, it comes here and we check if there is an update pending or not. function pollResult(data) { if (data.update) { if (isLogging) console.log("Update is: True"); // We have an update, so mark the transaction as complete handleTransaction("end"); initialize(); const table = document.querySelector("table"); table.classList.add('glow-EffectSuccess'); setTimeout(() => table.classList.remove('glow-EffectSuccess'), #pollUpdateDuration#); } else { if (isLogging) console.log("Update is: False"); handleTransaction("check"); } } function handleTransaction(action) { switch (action) { case "begin": transaction = Date.now(); // Start the transaction if (isLogging) console.log("Transaction started:", transaction); break; case "end": transaction = null; // End the transaction if (isLogging) console.log("Transaction finished"); break; case "check": if (!transaction) { if (isLogging) console.log("No active transaction to check"); return; } const elapsedTime = Date.now() - transaction; if (isLogging) console.log("Elapsed time is:", elapsedTime); if (elapsedTime > transactionTimeout) { if (isLogging) console.log("Transaction is late"); const table = document.querySelector("table"); table.classList.add('glow-EffectFail'); setTimeout(() => { handleTransaction("end"); // End the transaction table.classList.remove('glow-EffectFail'); initialize(); }, #pollUpdateDuration#); } else { if (isLogging) console.log("Transaction is running"); } break; } } //*********************************************** Sorting *************************************************************************** //************************************************************************************************************************************** function sortTable(columnIndex) { const tbody = document.querySelector("#sortableTable tbody"); const rows = Array.from(tbody.rows); // Load sorting state or update it if (columnIndex === -1) { columnIndex = sortDirection.activeColumn; // Use saved active column } else { // Update the active column and toggle the sort direction sortDirection.activeColumn = columnIndex; sortDirection.direction = sortDirection.direction === 'asc' ? 'desc' : 'asc'; sessionStorage.setItem(storageKey("activeColumn"), columnIndex); } const direction = sortDirection.direction; // Save updated state to localStorage localStorage.setItem(storageKey("sortDirection"), JSON.stringify(sortDirection)); // Separate pinned and unpinned rows based on the `data-pin` attribute const pinnedRows = rows.filter(row => row.dataset.pin === "on"); pinnedRows.sort((a, b) => { const nameA = a.cells[2]?.querySelector('input')?.value?.toLowerCase() || a.cells[2]?.textContent?.trim().toLowerCase() || ""; const nameB = b.cells[2]?.querySelector('input')?.value?.toLowerCase() || b.cells[2]?.textContent?.trim().toLowerCase() || ""; return nameA.localeCompare(nameB); }); const unpinnedRows = rows.filter(row => row.dataset.pin !== "on"); // Sort the unpinned rows based on the specified column index unpinnedRows.sort((a, b) => { let primaryA, primaryB, secondaryA, secondaryB; if (columnIndex === 2) { // Sorting by column 2 (Name) primaryA = a.cells[2]?.querySelector('input')?.value?.toLowerCase() || a.cells[2]?.textContent?.trim().toLowerCase() || ""; primaryB = b.cells[2]?.querySelector('input')?.value?.toLowerCase() || b.cells[2]?.textContent?.trim().toLowerCase() || ""; return direction === 'asc' ? primaryA.localeCompare(primaryB) : primaryB.localeCompare(primaryA); } else if (columnIndex === 3) { // Sorting by column 3 (State) with column 2 (Name) as secondary primaryA = a.cells[3]?.querySelector('.toggle-switch')?.dataset.state || a.cells[3]?.querySelector('.toggle-switch')?.textContent?.trim().toLowerCase() || a.cells[3]?.textContent?.trim().toLowerCase() || ""; primaryB = b.cells[3]?.querySelector('.toggle-switch')?.dataset.state || b.cells[3]?.querySelector('.toggle-switch')?.textContent?.trim().toLowerCase() || b.cells[3]?.textContent?.trim().toLowerCase() || ""; secondaryA = a.cells[2]?.querySelector('input')?.value?.toLowerCase() || a.cells[2]?.textContent?.trim().toLowerCase() || ""; secondaryB = b.cells[2]?.querySelector('input')?.value?.toLowerCase() || b.cells[2]?.textContent?.trim().toLowerCase() || ""; // Compare primary keys if (primaryA > primaryB) return direction === 'asc' ? 1 : -1; if (primaryA < primaryB) return direction === 'asc' ? -1 : 1; // Compare secondary keys return secondaryA.localeCompare(secondaryB); } else { // General case for other columns primaryA = a.cells[columnIndex]?.textContent?.trim().toLowerCase() || ""; primaryB = b.cells[columnIndex]?.textContent?.trim().toLowerCase() || ""; return direction === 'asc' ? primaryA.localeCompare(primaryB) : primaryB.localeCompare(primaryA); } }); // Append pinned rows first (in fixed order), then sorted unpinned rows tbody.append(...pinnedRows, ...unpinnedRows); // Highlight the sorted column markColumnHeader(); } // Underline the last active column header and apply direction indicators function markColumnHeader() { const headers = document.querySelectorAll('#sortableTable thead th'); // Remove the gradient classes from all headers headers.forEach(header => { header.classList.remove('ascSort', 'descSort'); }); // Add gradient class based on the sort direction const activeHeader = headers[sortDirection.activeColumn]; if (sortDirection.direction === 'asc') { activeHeader.classList.add('ascSort');} else { activeHeader.classList.add('descSort');} } //*********************************************** Initialization and Miscellaneous ************************************************** //************************************************************************************************************************************** // Call the function and handle the returned data function initialize() { if (isLogging) { console.log ("Initialize: fetching data") }; fetchData().then(data => { if (data) { if (isLogging) { console.log("Fetched JSON Data:", data) }; loadTableFromJSON(data); sortTable(-1 , null); //-1 Indicates to use the saved sort order, Null - Indicates to use the last Header used. } else { if (isLogging) { console.log("Failed to fetch data.") }; } }); } //Performs a complete refresh of the page. function refreshPage(timeout) { setTimeout(function() { location.reload(true); }, timeout); // 1000 milliseconds = 1 second } function rgb2hex(rgbString) { return '#' + rgbString.slice(4, -1).split(',').map(num => ('0' + parseInt(num.trim()).toString(16)).slice(-2) ).join(''); } document.addEventListener("DOMContentLoaded", function() { initialize(); }); </script> </body> </html> ''' return HTML } //******************************************************************************************************************************************************************************************* //************** //************** End Remote Control APplet Code //************** //*******************************************************************************************************************************************************************************************