├── .gitignore ├── LIFXColor.groovy ├── LIFXDayAndDusk.groovy ├── LIFXMasterApp.groovy ├── LIFXMultiZone.groovy ├── LIFXMultiZoneChild.groovy ├── LIFXPlusColor.groovy ├── LIFXTile.groovy ├── LIFXUnknown.groovy ├── LIFXWhite.groovy ├── LIFXWhiteMono.groovy ├── LIFX_Logo_Square_Black.png ├── README.md ├── build.gradle ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── packageManifest.json ├── settings.gradle └── src ├── main ├── groovy │ ├── Buffer.groovy │ ├── FrameAddress.groovy │ ├── Parser.groovy │ ├── ProtocolHeader.groovy │ └── ShortFrame.groovy └── resources │ └── META-INF │ └── plugin.xml └── test └── groovy ├── BufferTest.groovy ├── FrameAddressTest.groovy ├── ParserTest.groovy ├── ProtocolHeaderTest.groovy └── ShortFrameTest.groovy /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle/ 2 | ### Java ### 3 | *.class 4 | 5 | # BlueJ files 6 | *.ctxt 7 | 8 | # Mobile Tools for Java (J2ME) 9 | .mtj.tmp/ 10 | 11 | # Package Files # 12 | *.jar 13 | *.war 14 | *.ear 15 | 16 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 17 | hs_err_pid* 18 | 19 | 20 | ### Gradle ### 21 | .gradle 22 | /build/ 23 | 24 | # Ignore Gradle GUI config 25 | gradle-app.setting 26 | 27 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 28 | !gradle-wrapper.jar 29 | 30 | # Cache of project 31 | .gradletasknamecache 32 | 33 | # attach files 34 | .attach_pid* 35 | 36 | # output files 37 | out 38 | /changelog.txt 39 | /cidrtest.groovy 40 | -------------------------------------------------------------------------------- /LIFXColor.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Copyright 2019 Robert Heyes. All Rights Reserved 4 | * 5 | * This software is free for Private Use. You may use and modify the software without distributing it. 6 | * If you make a fork, and add new code, then you should create a pull request to add value, there is no 7 | * guarantee that your pull request will be merged. 8 | * 9 | * You may not grant a sublicense to modify and distribute this software to third parties without permission 10 | * from the copyright holder 11 | * Software is provided without warranty and your use of it is at your own risk. 12 | * 13 | */ 14 | 15 | metadata { 16 | definition(name: "LIFX Color", namespace: "robheyes", author: "Robert Alan Heyes", importUrl: 'https://raw.githubusercontent.com/robheyes/lifxcode/master/LIFXColor.groovy') { 17 | capability "Bulb" 18 | capability "ColorTemperature" 19 | capability "Polling" 20 | capability "Switch" 21 | capability "SwitchLevel" 22 | capability "Initialize" 23 | capability "ColorControl" 24 | capability "ColorMode" 25 | capability 'ChangeLevel' 26 | 27 | attribute "label", "string" 28 | attribute "group", "string" 29 | attribute "location", "string" 30 | attribute "lightStatus", "string" // is this used? 31 | attribute "wifiStatus", "map" // is this used? 32 | attribute "cancelLevelChange", "string" 33 | command "setState", ["MAP"] 34 | command 'setWaveform', [[name: 'Waveform*', type: 'ENUM', constraints:['SAW', 'SINE', 'HALF_SINE', 'TRIANGLE', 'PULSE']], [name: 'Color*', type: 'STRING'], [name: 'Transient', type: 'ENUM', constraints: ['true', 'false']], [name: 'Period', type: 'NUMBER'], [name: 'Cycles', type: 'NUMBER'], [name: 'Skew Ratio', type: 'NUMBER']] 35 | } 36 | 37 | preferences { 38 | input "useActivityLogFlag", "bool", title: "Enable activity logging", required: false 39 | input "useDebugActivityLogFlag", "bool", title: "Enable debug logging", required: false 40 | input "defaultTransition", "decimal", title: "Color map level transition time", description: "Set color time (seconds)", required: true, defaultValue: 0.0 41 | input "changeLevelStep", 'decimal', title: "Change level step size", description: "", required: false, defaultValue: 1 42 | input "changeLevelEvery", 'number', title: "Change Level every x milliseconds", description: "", required: false, defaultValue: 20 43 | } 44 | } 45 | 46 | @SuppressWarnings("unused") 47 | def installed() { 48 | initialize() 49 | } 50 | 51 | @SuppressWarnings("unused") 52 | def updated() { 53 | initialize() 54 | } 55 | 56 | def initialize() { 57 | state.transitionTime = defaultTransition 58 | state.useActivityLog = useActivityLogFlag 59 | state.useActivityLogDebug = useDebugActivityLogFlag 60 | state.changeLevelEvery = changeLevelEvery 61 | state.changeLevelStep = changeLevelStep 62 | unschedule() 63 | requestInfo() 64 | runEvery1Minute poll 65 | } 66 | 67 | @SuppressWarnings("unused") 68 | def refresh() { 69 | 70 | } 71 | 72 | @SuppressWarnings("unused") 73 | def poll() { 74 | parent.lifxQuery(device, 'LIGHT.GET_STATE') { List buffer -> sendPacket buffer } 75 | } 76 | 77 | def requestInfo() { 78 | parent.lifxQuery(device, 'LIGHT.GET_STATE') { List buffer -> sendPacket buffer } 79 | } 80 | 81 | def on() { 82 | sendActions parent.deviceOnOff('on', getUseActivityLog(), state.transitionTime ?: 0) 83 | } 84 | 85 | def off() { 86 | sendActions parent.deviceOnOff('off', getUseActivityLog(), state.transitionTime ?: 0) 87 | } 88 | 89 | @SuppressWarnings("unused") 90 | def setColor(Map colorMap) { 91 | sendActions parent.deviceSetColor(device, colorMap, getUseActivityLogDebug(), state.transitionTime ?: 0) 92 | } 93 | 94 | @SuppressWarnings("unused") 95 | def setHue(hue) { 96 | sendActions parent.deviceSetHue(device, hue, getUseActivityLog(), state.transitionTime ?: 0) 97 | } 98 | 99 | @SuppressWarnings("unused") 100 | def setSaturation(saturation) { 101 | sendActions parent.deviceSetSaturation(device, saturation, getUseActivityLog(), state.transitionTime ?: 0) 102 | } 103 | 104 | @SuppressWarnings("unused") 105 | def setColorTemperature(temperature, level = null, transitionTime = null) { 106 | sendActions parent.deviceSetColorTemperature(device, temperature, level, getUseActivityLog(), transitionTime ?: (state.transitionTime ?: 0)) 107 | } 108 | 109 | @SuppressWarnings("unused") 110 | def setLevel(level, duration = 0) { 111 | sendActions parent.deviceSetLevel(device, level as Number, getUseActivityLog(), duration) 112 | } 113 | 114 | @SuppressWarnings("unused") 115 | def setState(value) { 116 | sendActions parent.deviceSetState(device, stringToMap(value), getUseActivityLog(), state.transitionTime ?: 0) 117 | } 118 | 119 | def setWaveform(String waveform, String color, String isTransient = 'true', period = 5, cycles = 3.40282346638528860e38, skew = 0.5) { 120 | sendActions parent.deviceSetWaveform(device, isTransient.toBoolean(), stringToMap(color), period.toInteger(), cycles.toFloat(), skew.toFloat(), waveform) 121 | } 122 | 123 | @SuppressWarnings("unused") 124 | def startLevelChange(direction) { 125 | // logDebug "startLevelChange called with $direction" 126 | enableLevelChange() 127 | if (changeLevelStep && changeLevelEvery) { 128 | doLevelChange(direction == 'up' ? 1 : -1) 129 | } else { 130 | logDebug "No parameters" 131 | } 132 | } 133 | 134 | @SuppressWarnings("unused") 135 | def stopLevelChange() { 136 | sendEvent([name: "cancelLevelChange", value: 'yes', displayed: false]) 137 | } 138 | 139 | def enableLevelChange() { 140 | sendEvent([name: "cancelLevelChange", value: 'no', displayed: false]) 141 | } 142 | 143 | def doLevelChange(direction) { 144 | def cancelling = device.currentValue('cancelLevelChange') ?: 'no' 145 | if (cancelling == 'yes') { 146 | runInMillis 2 * (changeLevelEvery as Integer), "enableLevelChange" 147 | return; 148 | } 149 | def newLevel = device.currentValue('level') + ((direction as Float) * (changeLevelStep as Float)) 150 | def lastStep = false 151 | if (newLevel < 0) { 152 | newLevel = 0 153 | lastStep = true 154 | } else if (newLevel > 100) { 155 | newLevel = 100 156 | lastStep = true 157 | } 158 | sendActions parent.deviceSetLevel(device, newLevel, getUseActivityLog(), (changeLevelEvery - 1) / 1000) 159 | if (!lastStep) { 160 | runInMillis changeLevelEvery as Integer, "doLevelChange", [data: direction] 161 | } 162 | } 163 | 164 | 165 | private void sendActions(Map actions) { 166 | actions.commands?.each { item -> parent.lifxCommand(device, item.cmd, item.payload) { List buffer -> sendPacket buffer, true } } 167 | actions.events?.each { sendEvent it } 168 | } 169 | 170 | def parse(String description) { 171 | List events = parent.parseForDevice(device, description, getUseActivityLog()) 172 | events.collect { createEvent(it) } 173 | } 174 | 175 | private String myIp() { 176 | device.getDeviceNetworkId() 177 | } 178 | 179 | private void sendPacket(List buffer, boolean noResponseExpected = false) { 180 | String stringBytes = hubitat.helper.HexUtils.byteArrayToHexString parent.asByteArray(buffer) 181 | sendHubCommand( 182 | new hubitat.device.HubAction( 183 | stringBytes, 184 | hubitat.device.Protocol.LAN, 185 | [ 186 | type : hubitat.device.HubAction.Type.LAN_TYPE_UDPCLIENT, 187 | destinationAddress: myIp() + ":56700", 188 | encoding : hubitat.device.HubAction.Encoding.HEX_STRING, 189 | ignoreResponse : noResponseExpected 190 | ] 191 | ) 192 | ) 193 | } 194 | 195 | def getUseActivityLog() { 196 | if (state.useActivityLog == null) { 197 | state.useActivityLog = true 198 | } 199 | return state.useActivityLog 200 | } 201 | 202 | def setUseActivityLog(value) { 203 | log.debug("Setting useActivityLog to ${value ? 'true' : 'false'}") 204 | state.useActivityLog = value 205 | } 206 | 207 | Boolean getUseActivityLogDebug() { 208 | if (state.useActivityLogDebug == null) { 209 | state.useActivityLogDebug = false 210 | } 211 | return state.useActivityLogDebug as Boolean 212 | } 213 | 214 | def setUseActivityLogDebug(value) { 215 | log.debug("Setting useActivityLogDebug to ${value ? 'true' : 'false'}") 216 | state.useActivityLogDebug = value 217 | } 218 | 219 | void logDebug(msg) { 220 | if (getUseActivityLogDebug()) { 221 | log.debug msg 222 | } 223 | } 224 | 225 | void logInfo(msg) { 226 | if (getUseActivityLog()) { 227 | log.info msg 228 | } 229 | } 230 | 231 | void logWarn(String msg) { 232 | if (getUseActivityLog()) { 233 | log.warn msg 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /LIFXDayAndDusk.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Copyright 2019 Robert Heyes. All Rights Reserved 4 | * 5 | * This software is free for Private Use. You may use and modify the software without distributing it. 6 | * If you make a fork, and add new code, then you should create a pull request to add value, there is no 7 | * guarantee that your pull request will be merged. 8 | * 9 | * You may not grant a sublicense to modify and distribute this software to third parties without permission 10 | * from the copyright holder 11 | * Software is provided without warranty and your use of it is at your own risk. 12 | * 13 | */ 14 | 15 | metadata { 16 | definition(name: 'LIFX Day and Dusk', namespace: 'robheyes', author: 'Robert Alan Heyes', importUrl: 'https://raw.githubusercontent.com/robheyes/lifxcode/master/LIFXDayAndDusk.groovy') { 17 | capability "Bulb" 18 | capability "ColorTemperature" 19 | capability "ColorMode" 20 | capability "Polling" 21 | capability "Switch" 22 | capability "SwitchLevel" 23 | capability "Initialize" 24 | capability 'ChangeLevel' 25 | 26 | attribute "label", "string" 27 | attribute "group", "string" 28 | attribute "location", "string" 29 | attribute "cancelLevelChange", "string" 30 | 31 | command 'setWaveform', [[name: 'Waveform*', type: 'ENUM', constraints:['SAW', 'SINE', 'HALF_SINE', 'TRIANGLE', 'PULSE']], [name: 'Color*', type: 'STRING'], [name: 'Transient', type: 'ENUM', constraints: ['true', 'false']], [name: 'Period', type: 'NUMBER'], [name: 'Cycles', type: 'NUMBER'], [name: 'Skew Ratio', type: 'NUMBER']] 32 | } 33 | 34 | preferences { 35 | input "useActivityLogFlag", "bool", title: "Enable activity logging", required: false 36 | input "useDebugActivityLogFlag", "bool", title: "Enable debug logging", required: false 37 | input "defaultTransition", "decimal", title: "Level transition time", description: "Set transition time (seconds)", required: true, defaultValue: 0.0 38 | input "changeLevelStep", 'decimal', title: "Change level step size", description: "", required: false, defaultValue: 1 39 | input "changeLevelEvery", 'number', title: "Change Level every x milliseconds", description: "", required: false, defaultValue: 20 40 | 41 | } 42 | } 43 | 44 | @SuppressWarnings("unused") 45 | def installed() { 46 | initialize() 47 | } 48 | 49 | @SuppressWarnings("unused") 50 | def updated() { 51 | initialize() 52 | } 53 | 54 | def initialize() { 55 | state.transitionTime = defaultTransition 56 | state.useActivityLog = useActivityLogFlag 57 | state.useActivityLogDebug = useDebugActivityLogFlag 58 | state.changeLevelEvery = changeLevelEvery 59 | state.changeLevelStep = changeLevelStep 60 | unschedule() 61 | colorMode = 'CT' 62 | requestInfo() 63 | runEvery1Minute poll 64 | } 65 | 66 | @SuppressWarnings("unused") 67 | def refresh() { 68 | 69 | } 70 | 71 | @SuppressWarnings("unused") 72 | def poll() { 73 | parent.lifxQuery(device, 'LIGHT.GET_STATE') { List buffer -> sendPacket buffer } 74 | } 75 | 76 | def requestInfo() { 77 | parent.lifxQuery(device, 'LIGHT.GET_STATE') { List buffer -> sendPacket buffer } 78 | } 79 | 80 | def on() { 81 | sendActions parent.deviceOnOff('on', getUseActivityLog(), state.transitionTime ?: 0) 82 | } 83 | 84 | def off() { 85 | sendActions parent.deviceOnOff('off', getUseActivityLog(), state.transitionTime ?: 0) 86 | } 87 | 88 | @SuppressWarnings("unused") 89 | def setLevel(level, duration = 0) { 90 | sendActions parent.deviceSetLevel(device, level as Number, getUseActivityLog(), duration) 91 | } 92 | 93 | 94 | @SuppressWarnings("unused") 95 | def setColorTemperature(temperature, level = null, transitionTime = null) { 96 | sendActions parent.deviceSetColorTemperature(device, temperature, level, getUseActivityLog(), transitionTime ?: (state.transitionTime ?: 0)) 97 | } 98 | 99 | def setWaveform(String waveform, String color, String isTransient = 'true', period = 5, cycles = 3.40282346638528860e38, skew = 0.5) { 100 | sendActions parent.deviceSetWaveform(device, isTransient.toBoolean(), stringToMap(color), period.toInteger(), cycles.toFloat(), skew.toFloat(), waveform) 101 | } 102 | 103 | @SuppressWarnings("unused") 104 | def startLevelChange(direction) { 105 | // logDebug "startLevelChange called with $direction" 106 | enableLevelChange() 107 | if (changeLevelStep && changeLevelEvery) { 108 | doLevelChange(direction == 'up' ? 1 : -1) 109 | } else { 110 | logDebug "No parameters" 111 | } 112 | } 113 | 114 | @SuppressWarnings("unused") 115 | def stopLevelChange() { 116 | sendEvent([name: "cancelLevelChange", value: 'yes', displayed: false]) 117 | } 118 | 119 | def enableLevelChange() { 120 | sendEvent([name: "cancelLevelChange", value: 'no', displayed: false]) 121 | } 122 | 123 | def doLevelChange(direction) { 124 | def cancelling = device.currentValue('cancelLevelChange') ?: 'no' 125 | if (cancelling == 'yes') { 126 | runInMillis 2 * (changeLevelEvery as Integer), "enableLevelChange" 127 | return; 128 | } 129 | def newLevel = device.currentValue('level') + ((direction as Float) * (changeLevelStep as Float)) 130 | def lastStep = false 131 | if (newLevel < 0) { 132 | newLevel = 0 133 | lastStep = true 134 | } else if (newLevel > 100) { 135 | newLevel = 100 136 | lastStep = true 137 | } 138 | sendActions parent.deviceSetLevel(device, newLevel, getUseActivityLog(), (changeLevelEvery - 1) / 1000) 139 | if (!lastStep) { 140 | runInMillis changeLevelEvery as Integer, "doLevelChange", [data: direction] 141 | } 142 | } 143 | 144 | private void sendActions(Map actions) { 145 | actions.commands?.each { item -> parent.lifxCommand(device, item.cmd, item.payload) { List buffer -> sendPacket buffer, true } } 146 | actions.events?.each { sendEvent it } 147 | } 148 | 149 | def parse(String description) { 150 | List events = parent.parseForDevice(device, description, getUseActivityLog()) 151 | events.collect { createEvent(it) } 152 | } 153 | 154 | private def myIp() { 155 | device.getDeviceNetworkId() 156 | } 157 | 158 | private void sendPacket(List buffer, boolean noResponseExpected = false) { 159 | String stringBytes = hubitat.helper.HexUtils.byteArrayToHexString parent.asByteArray(buffer) 160 | sendHubCommand( 161 | new hubitat.device.HubAction( 162 | stringBytes, 163 | hubitat.device.Protocol.LAN, 164 | [ 165 | type : hubitat.device.HubAction.Type.LAN_TYPE_UDPCLIENT, 166 | destinationAddress: myIp() + ":56700", 167 | encoding : hubitat.device.HubAction.Encoding.HEX_STRING, 168 | ignoreResponse : noResponseExpected 169 | ] 170 | ) 171 | ) 172 | } 173 | 174 | def getUseActivityLog() { 175 | if (state.useActivityLog == null) { 176 | state.useActivityLog = true 177 | } 178 | return state.useActivityLog 179 | } 180 | 181 | def setUseActivityLog(value) { 182 | log.debug("Setting useActivityLog to ${value ? 'true' : 'false'}") 183 | state.useActivityLog = value 184 | } 185 | 186 | def getUseActivityLogDebug() { 187 | if (state.useActivityLogDebug == null) { 188 | state.useActivityLogDebug = false 189 | } 190 | return state.useActivityLogDebug 191 | } 192 | 193 | def setUseActivityLogDebug(value) { 194 | log.debug("Setting useActivityLogDebug to ${value ? 'true' : 'false'}") 195 | state.useActivityLogDebug = value 196 | } 197 | 198 | void logDebug(msg) { 199 | if (getUseActivityLogDebug()) { 200 | log.debug msg 201 | } 202 | } 203 | 204 | void logInfo(msg) { 205 | if (getUseActivityLog()) { 206 | log.info msg 207 | } 208 | } 209 | 210 | void logWarn(String msg) { 211 | if (getUseActivityLog()) { 212 | log.warn msg 213 | } 214 | } -------------------------------------------------------------------------------- /LIFXMasterApp.groovy: -------------------------------------------------------------------------------- 1 | import groovy.json.JsonSlurper 2 | import groovy.transform.Field 3 | 4 | /** 5 | * 6 | * Copyright 2019 Robert Heyes. All Rights Reserved 7 | * 8 | * This software is free for Private Use. You may use and modify the software without distributing it. 9 | * If you make a fork, and add new code, then you should create a pull request to add value, there is no 10 | * guarantee that your pull request will be merged. 11 | * 12 | * You may not grant a sublicense to modify and distribute this software to third parties without permission 13 | * from the copyright holder 14 | * Software is provided without warranty and your use of it is at your own risk. 15 | * 16 | */ 17 | 18 | @Field Integer extraProbesPerPass = 0 19 | @Field Boolean wantBufferCaching = false // should probably remove this? 20 | 21 | definition( 22 | name: 'LIFX Master', 23 | namespace: 'robheyes', 24 | author: 'Robert Alan Heyes', 25 | description: 'Provides for discovery and control of LIFX devices', 26 | category: 'Discovery', 27 | iconUrl: '', 28 | iconX2Url: '', 29 | iconX3Url: '' 30 | ) 31 | 32 | preferences { 33 | page(name: 'mainPage') 34 | page(name: 'discoveryPage') 35 | page(name: 'namedColorsPage') 36 | page(name: 'testBedPage') 37 | } 38 | 39 | 40 | @SuppressWarnings("unused") 41 | def mainPage() { 42 | dynamicPage(name: "mainPage", title: "Options", install: true, uninstall: true) { 43 | section { 44 | input 'interCommandPause', 'number', defaultValue: 50, title: 'Time between commands (milliseconds)', submitOnChange: true 45 | input 'maxPasses', 'number', title: 'Maximum number of passes', defaultValue: 2, submitOnChange: true 46 | input 'refreshInterval', 'number', title: 'Discovery page refresh interval (seconds).
WARNING: high refresh rates may interfere with discovery.', defaultValue: 6, submitOnChange: true 47 | input 'namePrefix', 'text', title: 'Device name prefix', description: 'If you specify a prefix then all your device names will be preceded by this value', submitOnChange: true 48 | input 'baseIpSegment', 'text', title: 'IP subnet(s)', description: 'e.g. 192.168.0 or 192.168.1, separate multiple subnets with commas', submitOnChange: true 49 | input 'savePreferences', 'button', title: 'Save', submitOnChange: true 50 | } 51 | discoveryPageLink() 52 | colorsPageLink() 53 | testBedPageLink() 54 | includeStyles() 55 | } 56 | } 57 | 58 | def mainPageLink() { 59 | section { 60 | href( 61 | name: 'Main page', 62 | page: 'mainPage', 63 | description: 'Back to main page' 64 | ) 65 | } 66 | } 67 | 68 | @SuppressWarnings("unused") 69 | def discoveryPage() { 70 | dynamicPage(name: 'discoveryPage', title: 'Discovery', refreshInterval: refreshInterval()) { 71 | section { 72 | paragraph "RECOMMENDATION: The device network id (DNI) for a LIFX device is based on its IP address. It is, therefore, advisable to configure your router's DHCP settings to use fixed IP addresses for all LIFX devices" 73 | paragraph '''ADVICE: I would suggest that it's a good idea to create groups for all your devices, and not just LIFX ones. This will make your rules and other automations dependent only on the groups and not the actual hardware, making it easier to replace devices at a later date with minimal disruption.
If you do this, then you may want to set the device prefix on the settings page to provide a way of clearly distinguishing between the group name and the device name.''' 74 | input 'discoverBtn', 'button', title: 'Discover devices' 75 | paragraph 'If you have added a new device, or not all of your devices are discovered the first time around, try the Discover only new devices button below' 76 | paragraph( 77 | null == atomicState.scanPass ? 78 | '' : 79 | ('DONE' == atomicState.scanPass) ? 80 | 'Scanning complete' : 81 | """Scanning your network for devices from subnets [${describeSubnets()}] 82 |
179 | h3.pre { 180 | background: #81BC00; 181 | font-size: larger; 182 | font-weight: bolder 183 | } 184 | h4.pre { 185 | background: #81BC00; 186 | font-size: larger 187 | } 188 | 189 | ul { 190 | list-style-type: none; 191 | } 192 | 193 | ul.device-group { 194 | background: #81BC00; 195 | padding: 0; 196 | } 197 | 198 | ul.device { 199 | background: #D9ECB1; 200 | } 201 | 202 | li.device-group { 203 | font-weight: bold; 204 | } 205 | 206 | li.device { 207 | font-weight: normal; 208 | } 209 | 210 | li.device-error { 211 | font-weight: bold; 212 | background: violet 213 | } 214 | 215 | button.hrefElem span.state-incomplete-text { 216 | display: block 217 | } 218 | 219 | button.hrefElem span { 220 | display: none 221 | } 222 | 223 | button.hrefElem br { 224 | display: none 225 | } 226 | 227 | /* Progress bar - modified from https://css-tricks.com/examples/ProgressBars/ */ 228 | .meter { 229 | height: 20px; /* Can be anything */ 230 | position: relative; 231 | background: #D9ECB1; 232 | -moz-border-radius: 5px; 233 | -webkit-border-radius: 5px; 234 | border-radius: 5px; 235 | padding: 0px; 236 | } 237 | 238 | .meter > span { 239 | display: block; 240 | height: 100%; 241 | border-top-right-radius: 2px; 242 | border-bottom-right-radius: 2px; 243 | border-top-left-radius: 5px; 244 | border-bottom-left-radius: 5px; 245 | background-color: #81BC00; 246 | position: relative; 247 | overflow: hidden; 248 | text-align: center; 249 | } 250 | /$ 251 | } 252 | 253 | String colorListHTML(String sortOrder) { 254 | builder = new StringBuilder() 255 | builder << '' 256 | colorList(sortOrder).each { 257 | builder << ''' 271 | ''' 272 | builder << '' 273 | builder << "" 274 | builder << "" 275 | builder << '' 276 | } 277 | builder << '
$it.name 
' 278 | builder.toString() 279 | } 280 | 281 | private String discoveryTextKnownDevices() { 282 | if ((atomicState.numDevices == null) || (0 == atomicState.numDevices)) { 283 | return 'No devices known' 284 | } 285 | 286 | def deviceList = describeDevices() // don't inline this 287 | // log.debug(deviceList) 288 | "I have found ${atomicState.numDevices} useable LIFX devices so far: ${deviceList}" 289 | } 290 | 291 | private String describeDevices() { 292 | def sorted = getKnownIps().sort { a, b -> (a.value.label as String).compareToIgnoreCase(b.value.label as String) } 293 | def grouped = sorted.groupBy { it.value.group } 294 | 295 | def builder = new StringBuilder() 296 | builder << '
    ' 297 | grouped.each { 298 | groupName, devices -> 299 | builder << "
  • $groupName
  • " 300 | builder << '
      ' 301 | devices.each { 302 | ip, device -> 303 | builder << (device.error ? 304 | "
    • ${device.label} (${device.error})
    • " 305 | : "
    • ${getDeviceNameLink(device)}
    • ") 306 | 307 | } 308 | 309 | builder << '
    ' 310 | } 311 | builder << '
' 312 | builder.toString() 313 | } 314 | 315 | private String getDeviceNameLink(device) { 316 | def realDevice = getChildDevice(device.ip) 317 | "$device.label" 318 | } 319 | 320 | Integer interCommandPauseMilliseconds(int pass = 1) { 321 | (settings.interCommandPause ?: 40) + 10 * (pass - 1) 322 | } 323 | 324 | Integer maxScanPasses() { 325 | settings.maxPasses ?: 2 326 | } 327 | 328 | Integer refreshInterval() { 329 | settings.refreshInterval ?: 6 330 | } 331 | 332 | String deviceNamePrefix() { 333 | settings.namePrefix ? settings.namePrefix + ' ' : "" 334 | } 335 | 336 | String ipSegment() { 337 | settings.baseIpSegment 338 | } 339 | 340 | @SuppressWarnings("unused") 341 | def updated() { 342 | logDebug 'LIFX updating' 343 | atomicState.subnets = null 344 | initialize() 345 | } 346 | 347 | @SuppressWarnings("unused") 348 | def installed() { 349 | logDebug 'LIFX installed' 350 | initialize() 351 | } 352 | 353 | @SuppressWarnings("unused") 354 | def uninstalled() { 355 | logDebug 'LIFX uninstalling - removing children' 356 | removeChildren() 357 | unsubscribe() 358 | } 359 | 360 | def initialize() { 361 | updateKnownDevices() 362 | } 363 | 364 | private void updateKnownDevices() { 365 | def knownDevices = knownDeviceLabels() 366 | atomicState.numDevices = knownDevices.size() 367 | } 368 | 369 | @SuppressWarnings("unused") 370 | def appButtonHandler(btn) { 371 | if (btn == "discoverBtn") { 372 | clearKnownIps() 373 | clearDeviceDefinitions() 374 | atomicState.packets = null 375 | removeChildren() 376 | discover() 377 | } else if (btn == 'discoverNewBtn') { 378 | clearKnownIpsWithErrors() 379 | discoverNew() 380 | } else if (btn == 'refreshExistingBtn') { 381 | refreshExisting() 382 | } else if (btn == 'clearCachesBtn') { 383 | clearCachedDescriptors() 384 | clearDeviceDefinitions() 385 | clearBufferCache() 386 | } else if (btn == 'testBtn') { 387 | testColorMapBuilder() 388 | } else if (btn == 'fetchBtn') { 389 | loadFromMultizone() 390 | } 391 | } 392 | 393 | def loadFromMultizone() { 394 | if (!multizone) { 395 | logDebug 'No multizone device' 396 | return 397 | } 398 | } 399 | 400 | 401 | def testColorMapBuilder() { 402 | Map map = buildColorMaps(settings.colors) 403 | def hsbkMaps = makeColorMaps map, settings.pattern as String 404 | } 405 | 406 | def setScanPass(pass) { 407 | atomicState.scanPass = pass ?: null 408 | } 409 | 410 | def refresh() { 411 | removeChildren() 412 | discovery('discovery') 413 | } 414 | 415 | def discoverNew() { 416 | endDiscovery() 417 | discovery('discovery') 418 | 419 | } 420 | 421 | def refreshExisting() { 422 | endDiscovery() 423 | } 424 | 425 | private void discover() { 426 | logInfo("Discovery started") 427 | String[] subnets = getSubnets() 428 | if (0 == subnets.size()) { 429 | log.warn "Can't discover the hub's subnet!" 430 | return 431 | } 432 | 433 | clearCachedDescriptors() 434 | int scanPasses = maxScanPasses() 435 | Map queue = prepareQueue(makeVersionPacket()) 436 | subnets.each { 437 | String subnet = it 438 | 1.upto(scanPasses) { 439 | setScanPass(it) 440 | scanNetwork queue, subnet, it 441 | } 442 | } 443 | // sendEvent name: 'progress', value: 0 444 | queue.size = queue.ipAddresses.size() 445 | runInMillis(50, 'processQueue', [data: queue]) 446 | } 447 | 448 | private String makeVersionPacket() { 449 | makeDiscoveryPacketString typeOfMessage('DEVICE.GET_VERSION') 450 | } 451 | 452 | private void scanNetwork(Map queue, String subnet, Number pass) { 453 | 1.upto(pass + extraProbesPerPass) { 454 | 1.upto(254) { 455 | def ipAddress = subnet + it 456 | queue.ipAddresses << ipAddress 457 | } 458 | } 459 | } 460 | 461 | def handleOutstandingDevices(Map outstandingDevices, Map queue) { 462 | logDebug("Processing outstanding devices") 463 | queue.attempts++ 464 | if (queue.attempts > 5) { 465 | return 466 | } 467 | outstandingDevices.each { 468 | mac, data -> 469 | queue.ipAddresses << data.ip 470 | } 471 | 472 | queue.size = queue.ipAddresses.size() 473 | runInMillis(50, 'processQueue', [data: queue]) 474 | } 475 | 476 | 477 | private Map prepareQueue(String packet, int delay = 20) { 478 | [packet: packet, ipAddresses: [], delay: delay, attempts: 0] 479 | } 480 | 481 | @SuppressWarnings("unused") 482 | private processQueue(Map queue) { 483 | def oldPercent = calculateQueuePercentage(queue) 484 | if (isQueueEmpty(queue)) { 485 | endDiscovery() 486 | return 487 | } 488 | def data = getNext(queue) 489 | sendPacket data.ipAddress, data.packet 490 | def newPercent = calculateQueuePercentage(queue) 491 | if (oldPercent != newPercent) { 492 | showProgress(newPercent) 493 | } 494 | runInMillis(queue.delay, 'processQueue', [data: queue]) 495 | } 496 | 497 | private Map getNext(Map queue) { 498 | String first = queue.ipAddresses.first() 499 | queue.ipAddresses = queue.ipAddresses.tail() 500 | [ipAddress: first, packet: queue.packet] 501 | } 502 | 503 | private isQueueEmpty(Map queue) { 504 | queue.ipAddresses.isEmpty() 505 | } 506 | 507 | private int calculateQueuePercentage(Map queue) { 508 | 100 - (int) ((queue.ipAddresses.size() * 100) / queue.size as Long) 509 | } 510 | 511 | private void sendPacket(String ipAddress, String bytes) { 512 | broadcast bytes, ipAddress 513 | } 514 | 515 | private void broadcast(String stringBytes, String ipAddress) { 516 | sendHubCommand( 517 | new hubitat.device.HubAction( 518 | stringBytes, 519 | hubitat.device.Protocol.LAN, 520 | [ 521 | type : hubitat.device.HubAction.Type.LAN_TYPE_UDPCLIENT, 522 | destinationAddress: ipAddress + ":56700", 523 | encoding : hubitat.device.HubAction.Encoding.HEX_STRING, 524 | ignoreWarning : true, 525 | callback : "discoveryParse" 526 | ] 527 | ) 528 | ) 529 | } 530 | 531 | String discoveryType() { 532 | return atomicState.discoveryType 533 | } 534 | 535 | private void discovery(String discoveryType) { 536 | atomicState.discoveryType = discoveryType 537 | atomicState.scanPass = null 538 | updateKnownDevices() 539 | clearDeviceDefinitions() 540 | atomicState.progressPercent = 0 541 | // def discoveryDevice = addChildDevice 'robheyes', 'LIFX Discovery', 'LIFX Discovery' 542 | // subscribe discoveryDevice, 'lifxdiscovery.complete', removeDiscoveryDevice 543 | // subscribe discoveryDevice, 'lifxdiscovery.outstanding', handleOutstandingDevices 544 | // subscribe discoveryDevice, 'progress', progress 545 | discover() 546 | } 547 | 548 | @SuppressWarnings("unused") 549 | def progress(evt) { 550 | def percent = evt.getIntegerValue() 551 | showProgress(percent) 552 | } 553 | 554 | private void showProgress(int percent) { 555 | Integer delta = percent - (atomicState.progressPercent ?: 0) 556 | if (delta.abs() > 10) { 557 | atomicState.progressPercent = percent 558 | } 559 | } 560 | 561 | def getProgressPercentage() { 562 | def percent = atomicState.progressPercent ?: 0 563 | "$percent%" 564 | } 565 | 566 | void endDiscovery() { 567 | logInfo 'Discovery complete' 568 | // unsubscribe() 569 | atomicState.scanPass = 'DONE' 570 | try { 571 | deleteChildDevice 'LIFX Discovery' 572 | } catch (Exception e) { 573 | // don't care, let it fail 574 | } 575 | } 576 | 577 | void removeChildren() { 578 | logInfo "Removing child devices" 579 | childDevices.each { 580 | if (it != null) { 581 | deleteChildDevice it.deviceNetworkId 582 | } 583 | } 584 | clearKnownIps() 585 | updateKnownDevices() 586 | } 587 | 588 | @SuppressWarnings("unused") 589 | def enableLevelChange(com.hubitat.app.DeviceWrapper device) { 590 | sendEvent(device, [name: "cancelLevelChange", value: 'no', displayed: false]) 591 | } 592 | 593 | Map deviceOnOff(String value, Boolean displayed, duration = 0) { 594 | def actions = makeActions() 595 | actions.commands << makeCommand('LIGHT.SET_POWER', [powerLevel: value == 'on' ? 65535 : 0, duration: duration * 1000]) 596 | actions.events << [name: "switch", value: value, displayed: displayed, data: [syncing: "false"]] 597 | actions 598 | } 599 | 600 | Map deviceSetZones(com.hubitat.app.DeviceWrapper device, Map zoneMap, Boolean extMZ, Boolean displayed = true, String power = 'on') { 601 | def actions = makeActions() 602 | if (extMZ) { 603 | actions.commands << makeCommand('MULTIZONE.SET_EXTENDED_COLOR_ZONES', zoneMap) 604 | } else { 605 | for (int i = 0; i < zoneMap.zone_count; i++) { 606 | if (zoneMap.colors[i]) { 607 | actions.commands << makeCommand('MULTIZONE.SET_COLOR_ZONES', [start_index: i, end_index: i, color: zoneMap.colors[i], duration: zoneMap.duration]) 608 | } 609 | } 610 | actions.commands << makeCommand('MULTIZONE.SET_COLOR_ZONES', [color: [:], apply: 2]) 611 | } 612 | 613 | if (null != power && device.currentSwitch != power) { 614 | def powerLevel = 'on' == power ? 65535 : 0 615 | actions.commands << makeCommand('LIGHT.SET_POWER', [powerLevel: powerLevel, duration: zoneMap.duration * 1000]) 616 | actions.events << [name: "switch", value: power, displayed: displayed, data: [syncing: "false"]] 617 | } 618 | actions 619 | } 620 | 621 | Map deviceSetMultiZoneEffect(String effectType, Integer speed, String direction) { 622 | def actions = makeActions() 623 | def params = new int[8] 624 | params[1] = direction == 'reverse' ? 0 : 1 625 | actions.commands << makeCommand('MULTIZONE.SET_MULTIZONE_EFFECT', [instanceId: 5439, type: effectType == 'MOVE' ? 1 : 0, speed: effectType == 'OFF' ? 0 : speed * 1000, parameters: params]) 626 | actions 627 | } 628 | 629 | Map deviceSetTileEffect(String effectType, Integer speed, Integer palette_count, List colors) { 630 | def actions = makeActions() 631 | Integer typeInt 632 | switch (effectType) { 633 | case 'OFF': 634 | typeInt = 0 635 | case 'MORPH': 636 | typeInt = 2 637 | case 'FLAME': 638 | typeInt = 3 639 | } 640 | actions.commands << makeCommand('TILE.SET_TILE_EFFECT', [instanceId: 5439, type: typeInt, speed: speed * 1000, palette_count: palette_count, palette: hsbkList]) 641 | actions 642 | } 643 | 644 | Map deviceSetColor(com.hubitat.app.DeviceWrapper device, Map colorMap, Boolean displayed, duration = 0) { 645 | def hsbkMap = getCurrentHSBK device 646 | hsbkMap << getScaledColorMap(colorMap) 647 | hsbkMap.duration = (colorMap.duration ?: duration) 648 | deviceSetHSBKAndPower(device, duration, hsbkMap, displayed) 649 | } 650 | 651 | Map deviceSetColor(com.hubitat.app.DeviceWrapper device, String colorMap, Boolean displayed, duration = 0) { 652 | deviceSetColor(device, stringToMap(colorMap), displayed, duration) 653 | } 654 | 655 | Map deviceSetHue(com.hubitat.app.DeviceWrapper device, Number hue, Boolean displayed, duration = 0) { 656 | def hsbkMap = getCurrentHSBK device 657 | hsbkMap.hue = scaleUp100 hue 658 | hsbkMap.duration = duration 659 | 660 | deviceSetHSBKAndPower(device, duration, hsbkMap, displayed) 661 | } 662 | 663 | Map deviceSetSaturation(com.hubitat.app.DeviceWrapper device, Number saturation, Boolean displayed, duration = 0) { 664 | def hsbkMap = getCurrentHSBK device 665 | hsbkMap.saturation = scaleUp100 saturation 666 | hsbkMap.duration = duration 667 | 668 | deviceSetHSBKAndPower(device, duration, hsbkMap, displayed) 669 | } 670 | 671 | Map deviceSetColorTemperature(com.hubitat.app.DeviceWrapper device, Number temperature, level = null, Boolean displayed=false, duration = 0) { 672 | def hsbkMap = [kelvin: temperature, duration: duration, brightness: scaleUp(level ?: device.currentLevel as Long, 100)] 673 | 674 | deviceSetHSBKAndPower(device, duration, hsbkMap, displayed) 675 | } 676 | 677 | Map deviceSetIRLevel(com.hubitat.app.DeviceWrapper device, Number level, Boolean displayed, duration = 0) { 678 | def actions = makeActions() 679 | actions.commands << makeCommand('LIGHT.SET_INFRARED', [irLevel: scaleUp100(level)]) 680 | actions.events << [name: "IRLevel", value: level, displayed: displayed, data: [syncing: "false"]] 681 | actions 682 | } 683 | 684 | Map deviceSetLevel(com.hubitat.app.DeviceWrapper device, Number level, Boolean displayed, Number duration = 0.0) { 685 | if ((null == level || level <= 0) && 0 == duration) { 686 | return deviceOnOff('off', displayed) 687 | } 688 | if (level > 100) { 689 | level = 100 690 | } 691 | def hsbkMap = getCurrentHSBK(device) 692 | if (device.hasCapability('ColorMode') && (device.currentValue('colorMode') == 'CT') || hsbkMap.saturation == 0) { 693 | hsbkMap.brightness = scaleUp100 level 694 | hsbkMap.hue = 0 695 | hsbkMap.saturation = 0 696 | hsbkMap.duration = duration 697 | } else { 698 | hsbkMap = [ 699 | hue : scaleUp100(device.currentHue), 700 | saturation: scaleUp100(device.currentSaturation), 701 | brightness: scaleUp100(level), 702 | kelvin : device.currentColorTemperature, 703 | duration : duration, 704 | ] 705 | } 706 | 707 | deviceSetHSBKAndPower(device, duration, hsbkMap, displayed) 708 | } 709 | 710 | Map deviceSetState(com.hubitat.app.DeviceWrapper device, Map myStateMap, Boolean displayed, duration = 0) { 711 | String power = myStateMap.power 712 | Number level = (myStateMap.level ?: myStateMap.brightness) as Number 713 | def kelvin = myStateMap.kelvin ?: myStateMap.temperature 714 | String color = myStateMap.color ?: myStateMap.colour 715 | duration = (myStateMap.duration ?: duration) as Integer 716 | 717 | if (color) { 718 | Map myColor 719 | myColor = (null == color) ? null : lookupColor(color.replace('_', ' ')) 720 | Map realColor = [ 721 | hue : scaleUp(myColor.h ?: 0, 360), 722 | saturation: scaleUp100(myColor.s ?: 0), 723 | brightness: scaleUp100(level ?: (myColor.v ?: 50)), 724 | kelvin : kelvin ?: device.currentColorTemperature, // not sure this makes any sense 725 | duration : duration 726 | ] 727 | if (myColor.name) { 728 | realColor.name = myColor.name 729 | } 730 | return deviceSetHSBKAndPower(device, duration, realColor, displayed, power) 731 | } 732 | if (kelvin) { 733 | Map realColor = [ 734 | hue : 0, // does this make sense? Yes, because of Groovy truth 735 | saturation: 0, 736 | kelvin : kelvin, 737 | brightness: scaleUp100(level ?: 100), 738 | duration : duration, 739 | name : null 740 | ] 741 | return deviceSetHSBKAndPower(device, duration, realColor, displayed, power) 742 | } 743 | if (level) { 744 | return deviceSetLevel(device, level, displayed, duration) 745 | } 746 | return [:] // do nothing 747 | } 748 | 749 | Map deviceSetWaveform(com.hubitat.app.DeviceWrapper device, Boolean isTransient, Map colorMap, Integer period, Float cycles, Float skew_ratio, String waveform) { 750 | def actions = makeActions() 751 | Map waveMap = [SAW: 0, SINE: 1, HALF_SINE: 2, TRIANGLE: 3, PULSE: 4] 752 | Integer waveInt = waveMap[waveform] ?: 1 //default to SINE 753 | Integer scaled_skew = 0 754 | if (waveInt == 4) { 755 | scaled_skew = (skew_ratio * 65535) - 32768 756 | } 757 | String namedColor = colorMap.color ?: colorMap.colour 758 | Map realColor = getCurrentHSBK device 759 | if (namedColor) { 760 | Map myColor 761 | myColor = (null == namedColor) ? null : lookupColor(namedColor.replace('_', ' ')) 762 | realColor << [ 763 | hue : scaleUp(myColor.h ?: 0, 360), 764 | saturation: scaleUp100(myColor.s ?: 0), 765 | brightness: scaleUp100(myColor.v ?: 50) 766 | ] 767 | } else { 768 | realColor << getScaledColorMap(colorMap) 769 | } 770 | actions.commands << makeCommand('LIGHT.SET_WAVEFORM', [transient: isTransient ? 1 : 0, color: realColor, period: period * 1000, cycles: cycles, skew_ratio: scaled_skew, waveform: waveInt]) 771 | actions 772 | } 773 | 774 | List parseForDevice(device, String description, Boolean displayed, Boolean updateDevice = false) { 775 | Map header = parseHeaderFromDescription description 776 | switch (header.type) { 777 | case messageType["DEVICE.STATE_VERSION"]: 778 | log.warn("STATE_VERSION type ignored") 779 | return [] 780 | case messageType['DEVICE.STATE_LABEL']: 781 | if (updateDevice) { 782 | def data = parsePayload 'DEVICE.STATE_LABEL', header 783 | device.setLabel(officialDeviceName(data.label.trim())) 784 | } 785 | return [] 786 | case messageType['DEVICE.STATE_GROUP']: 787 | def data = parsePayload 'DEVICE.STATE_GROUP', header 788 | String group = data.label 789 | return [[name: 'group', value: group]] 790 | case messageType['DEVICE.STATE_LOCATION']: 791 | def data = parsePayload 'DEVICE.STATE_LOCATION', header 792 | String location = data.label 793 | return [[name: 'location', value: location]] 794 | case messageType['DEVICE.STATE_HOST_INFO']: 795 | def data = parsePayload 'DEVICE.STATE_HOST_INFO', header 796 | break 797 | case messageType['DEVICE.STATE_HOST_FIRMWARE']: 798 | def data = parsePayload 'DEVICE.STATE_HOST_FIRMWARE', header 799 | String version = "${data.version_major}.${data.version_minor}" 800 | return [[name: 'firmware', data: version, displayed: false]] 801 | break 802 | case messageType['DEVICE.STATE_INFO']: 803 | def data = parsePayload 'DEVICE.STATE_INFO', header 804 | break 805 | case messageType['LIGHT.STATE']: 806 | def data = parsePayload 'LIGHT.STATE', header 807 | if (updateDevice) { 808 | def label = data.label.trim() 809 | def deviceName = officialDeviceName(label) 810 | device.setName deviceName 811 | device.setLabel deviceName 812 | } 813 | List result = [[name: "level", value: intScaleDown100(data.color.brightness), displayed: displayed]] 814 | if (device.hasCapability('Color Control')) { 815 | result.add([name: "hue", value: intScaleDown100(data.color.hue), displayed: displayed]) 816 | result.add([name: "saturation", value: intScaleDown100(data.color.saturation), displayed: displayed]) 817 | } 818 | if (device.hasCapability('Color Temperature')) { 819 | result.add([name: "colorTemperature", value: data.color.kelvin as Integer, displayed: displayed]) 820 | } 821 | if (device.hasCapability('Switch')) { 822 | result.add([name: 'switch', value: (data.power == 65535) ? 'on' : 'off', displayed: displayed]) 823 | } 824 | return result 825 | case messageType['LIGHT.STATE_INFRARED']: 826 | def data = parsePayload 'LIGHT.STATE_INFRARED', header 827 | return [[name: 'IRLevel', value: intScaleDown100(data.irLevel), displayed: displayed]] 828 | case messageType['DEVICE.STATE_POWER']: 829 | Map data = parsePayload 'DEVICE.STATE_POWER', header 830 | return [[name: "switch", value: (data.powerLevel as Integer == 0 ? "off" : "on"), displayed: displayed, data: [syncing: "false"]],] 831 | case messageType['LIGHT.STATE_POWER']: 832 | Map data = parsePayload 'LIGHT.STATE_POWER', header 833 | return [ 834 | [name: "switch", value: (data.powerLevel as Integer == 0 ? "off" : "on"), displayed: displayed, data: [syncing: "false"]], 835 | [name: "level", value: (data.powerLevel as Integer == 0 ? 0 : 100), displayed: displayed, data: [syncing: "false"]] 836 | ] 837 | /* 838 | case messageType['DEVICE.ACKNOWLEDGEMENT']: 839 | Byte sequence = header.sequence 840 | clearExpectedAckFor device, sequence 841 | return [] 842 | */ 843 | case messageType['MULTIZONE.STATE_MULTIZONE']: 844 | Map data = parsePayload 'MULTIZONE.STATE_MULTIZONE', header 845 | def theZones = getChildDevice(device.getDeviceNetworkId()).loadLastMultizone() 846 | theZones.currentIndex = data.index 847 | theZones.zone_count = data.zone_count 848 | for (int i = 0; i < 8; i++) { 849 | theZones.colors[(i + data.index)] = data.colors[i] 850 | } 851 | def multizoneHtml = renderMultizone(theZones) 852 | return [ 853 | [name: 'multizone', value: multizoneHtml, data: theZones, displayed: true] 854 | ] 855 | case messageType['MULTIZONE.STATE_EXTENDED_COLOR_ZONES']: 856 | Map data = parsePayload 'MULTIZONE.STATE_EXTENDED_COLOR_ZONES', header 857 | // String compressed = compressMultizoneData data 858 | def multizoneHtml = renderMultizone(data) 859 | return [ 860 | [name: 'multizone', value: multizoneHtml, data: data, displayed: true], 861 | ] 862 | case messageType['MULTIZONE.STATE_MULTIZONE_EFFECT']: 863 | Map data = parsePayload 'MULTIZONE.STATE_MULTIZONE_EFFECT', header 864 | return [ 865 | [name: 'effect', value: data.type == 1 ? 'MOVE' : 'OFF', displayed: true] 866 | ] 867 | case messageType['TILE.STATE_TILE_EFFECT']: 868 | Map data = parsePayload 'TILE.STATE_TILE_EFFECT', header 869 | def effects = ['OFF', 'RESERVED', 'MORPH', 'FLAME'] 870 | return [ 871 | [name: 'effect', value: effects[data.type as int], displayed: true] 872 | ] 873 | default: 874 | logWarn "Unhandled response for ${header.type}" 875 | return [] 876 | } 877 | return [] 878 | } 879 | 880 | @SuppressWarnings("unused") 881 | void discoveryParse(response) { 882 | def description = response.description 883 | def actions = makeActions() 884 | Map deviceParams = parseDeviceParameters description 885 | String ip = convertIpLong(deviceParams.ip as String) 886 | Map parsed = parseHeader deviceParams 887 | 888 | final String mac = deviceParams.mac 889 | switch (parsed.type) { 890 | case messageType['DEVICE.STATE_VERSION']: 891 | if (isKnownIp(ip)) { 892 | break 893 | } 894 | def existing = getDeviceDefinition mac 895 | if (!existing) { 896 | createDeviceDefinition parsed, ip, mac 897 | } 898 | actions.commands << [ip: ip, type: messageType['DEVICE.GET_GROUP']] 899 | break 900 | case messageType['DEVICE.STATE_LABEL']: 901 | def data = parsePayload 'DEVICE.STATE_LABEL', parsed 902 | def device = updateDeviceDefinition mac, ip, [label: officialDeviceName(data.label as String)] 903 | if (device) { 904 | sendEvent ip, [name: 'label', value: officialDeviceName(data.label as String)] 905 | sendEvent ip, [name: 'deviceName', value: officialDeviceName(data.label as String)] 906 | } 907 | break 908 | case messageType['DEVICE.STATE_GROUP']: 909 | def data = parsePayload 'DEVICE.STATE_GROUP', parsed 910 | def device = updateDeviceDefinition mac, ip, [group: data.label] 911 | if (device) { 912 | sendEvent ip, [name: 'group', value: data.label] 913 | } 914 | actions.commands << [ip: ip, type: messageType['DEVICE.GET_LOCATION']] 915 | break 916 | case messageType['DEVICE.STATE_LOCATION']: 917 | def data = parsePayload 'DEVICE.STATE_LOCATION', parsed 918 | def device = updateDeviceDefinition mac, ip, [location: data.label] 919 | if (device) { 920 | sendEvent ip, [name: 'location', value: data.label] 921 | } 922 | actions.commands << [ip: ip, type: messageType['DEVICE.GET_LABEL']] 923 | break 924 | case messageType['DEVICE.STATE_WIFI_INFO']: 925 | break 926 | case messageType['DEVICE.STATE_INFO']: 927 | break 928 | } 929 | sendDiscoveryActions actions 930 | } 931 | 932 | private void sendDiscoveryActions(Map actions) { 933 | actions.commands?.eachWithIndex { item, index -> sendDiscoveryCommand item.ip as String, item.type as int, 1 } 934 | actions.events?.each { sendEvent it } 935 | } 936 | 937 | private void sendCommand(String deviceAndType, Map payload = [:], boolean responseRequired, Closure sender) { 938 | def buffer = [] 939 | sender makePacket(buffer, deviceAndType, payload, responseRequired) 940 | } 941 | 942 | private void sendDiscoveryCommand(String ipAddress, int messageType, int pass = 1) { 943 | String stringBytes = makeDiscoveryPacketString messageType 944 | sendPacket ipAddress, stringBytes 945 | pauseExecution(interCommandPauseMilliseconds(pass)) 946 | } 947 | 948 | private Map getPacketStringCache() { 949 | if (null == atomicState.packets) { 950 | atomicState.packets = new HashMap() 951 | } 952 | atomicState.packets 953 | } 954 | 955 | private storePacket(String messageType, Object bytes) { 956 | packets = getPacketStringCache() 957 | packets[messageType] = bytes 958 | atomicState.packets = packets 959 | } 960 | 961 | private Object getCachedPacket(String messageType) { 962 | def cache = getPacketStringCache() 963 | def bytes = cache.get(messageType) 964 | bytes 965 | } 966 | 967 | private String makeDiscoveryPacketString(int messageType) { 968 | def bytes = getCachedPacket(messageType as String) 969 | if (bytes) { 970 | return bytes as String 971 | } 972 | def buffer = [] 973 | simpleMakePacket buffer, messageType, true, [] 974 | def rawBytes = asByteArray(buffer) 975 | String stringBytes = hubitat.helper.HexUtils.byteArrayToHexString rawBytes 976 | storePacket(messageType as String, stringBytes) 977 | stringBytes 978 | } 979 | 980 | byte[] asByteArray(List buffer) { 981 | (buffer.each { it as byte }) as byte[] 982 | } 983 | 984 | @SuppressWarnings("unused") 985 | void lifxQuery(com.hubitat.app.DeviceWrapper device, String deviceAndType, Closure sender) { 986 | sendCommand deviceAndType, [:], true, sender 987 | } 988 | 989 | void lifxQuery(com.hubitat.app.DeviceWrapper device, String deviceAndType, Map payload, Closure sender) { 990 | sendCommand deviceAndType, payload, true, sender 991 | } 992 | 993 | @SuppressWarnings("unused") 994 | void lifxQuery(com.hubitat.app.DeviceWrapper device, List deviceAndType, Closure sender) { 995 | deviceAndType.each { sendCommand it, [:], true, sender } 996 | } 997 | 998 | @SuppressWarnings("unused") 999 | void lifxCommand(com.hubitat.app.DeviceWrapper device, String deviceAndType, Map payload, Closure sender) { 1000 | sendCommand deviceAndType, payload, false, sender 1001 | } 1002 | 1003 | List makePacket(List buffer, String deviceAndType, Map payload, Boolean responseRequired = true) { 1004 | def tryCache = responseRequired && payload.isEmpty() 1005 | 1006 | if (tryCache) { 1007 | def bytes = getCachedPacket(deviceAndType) 1008 | if (bytes) { 1009 | return bytes as List 1010 | } 1011 | } 1012 | 1013 | def listPayload = makePayload(deviceAndType, payload) 1014 | int messageType = messageType[deviceAndType] 1015 | simpleMakePacket(buffer, messageType, responseRequired, listPayload) 1016 | storePacket(deviceAndType, buffer) 1017 | buffer 1018 | } 1019 | 1020 | private List simpleMakePacket(List buffer, int messageType, Boolean responseRequired = false, List payload = []) { 1021 | byte[] targetAddress = [0, 0, 0, 0, 0, 0] 1022 | createFrame buffer, targetAddress.every { it == 0 } 1023 | createFrameAddress buffer, targetAddress, false, responseRequired, 0 as byte 1024 | createProtocolHeader buffer, messageType as short 1025 | createPayload buffer, payload as byte[] 1026 | 1027 | put buffer, 0, buffer.size() as short 1028 | 1029 | return buffer 1030 | } 1031 | 1032 | Boolean isKnownIp(String ip) { 1033 | def knownIps = getKnownIps() 1034 | null != knownIps[ip] 1035 | } 1036 | 1037 | private void expectAckFor(com.hubitat.app.DeviceWrapper device, Byte sequence, List buffer) { 1038 | def expected = atomicState.expectedAckFor ?: [:] 1039 | expected[device.getDeviceNetworkId() as String] = [sequence: sequence, buffer: buffer] 1040 | atomicState.expectedAckFor = expected 1041 | } 1042 | 1043 | private Byte ackWasExpected(com.hubitat.app.DeviceWrapper device) { 1044 | def expected = atomicState.expectedAckFor ?: [:] 1045 | expected[device.getDeviceNetworkId() as String]?.sequence as Byte 1046 | } 1047 | 1048 | private void clearExpectedAckFor(com.hubitat.app.DeviceWrapper device, Byte sequence) { 1049 | def expected = atomicState.expectedAckFor ?: [:] 1050 | expected.remove(device.getDeviceNetworkId()) 1051 | atomicState.expectedAckFor = expected 1052 | } 1053 | 1054 | private List getBufferToResend(com.hubitat.app.DeviceWrapper device, Byte sequence) { 1055 | def expected = atomicState.expectedAckFor ?: [:] 1056 | Map expectation = expected[device.getDeviceNetworkId()] 1057 | if (null == expectation) { 1058 | null 1059 | } 1060 | if (expectation?.sequence == sequence) { 1061 | expectation?.buffer 1062 | } else { 1063 | null 1064 | } 1065 | } 1066 | 1067 | int typeOfMessage(String deviceAndType) { messageType[deviceAndType] } 1068 | 1069 | void clearCachedDescriptors() { atomicState.cachedDescriptors = null } 1070 | 1071 | String getSubnet() { 1072 | if (null != settings.baseIpSegment) { 1073 | def baseIp = parseIPSegment(settings.baseIpSegment) 1074 | if (baseIp != null) { 1075 | return baseIp 1076 | } 1077 | } 1078 | def ip = getHubIP() 1079 | def m = ip =~ /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.)\d{1,3}/ 1080 | if (!m) { 1081 | logWarn('ip does not match pattern') 1082 | return null 1083 | } 1084 | return m.group(1) 1085 | } 1086 | 1087 | String describeSubnets() { 1088 | def subnets = getSubnets() 1089 | subnets.join ',' 1090 | } 1091 | 1092 | String[] getSubnets() { 1093 | if (atomicState.subnets != null) { 1094 | return atomicState.subnets 1095 | } 1096 | def baseIps = getBaseIps() 1097 | if (baseIps?.size() != 0) { 1098 | atomicState.subnets = baseIps 1099 | return baseIps 1100 | } 1101 | def ip = getHubIP() 1102 | def m = ip =~ /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.)\d{1,3}/ 1103 | if (!m) { 1104 | logWarn "ip $ip does not match pattern" 1105 | return null 1106 | } 1107 | atomicState.subnets = [m.group(1)] 1108 | return atomicState.subnets 1109 | } 1110 | 1111 | String[] getBaseIps() { 1112 | String ipSegment = settings.baseIpSegment 1113 | return ipSegment == null ? [] : ipSegment.split(/,/)?.collect { return parseIPSegment(it) } 1114 | } 1115 | 1116 | private String parseIPSegment(String ipSegment) { 1117 | def m = ipSegment =~ /^(\d{1,3}\.\d{1,3}\.\d{1,3})/ 1118 | if (!m) { 1119 | logDebug 'null segment' 1120 | return null 1121 | } 1122 | def segment = m.group(1) 1123 | (segment.endsWith('.')) ? segment : segment + '.' 1124 | } 1125 | 1126 | // returns [h, s, v, name] 1127 | private Map lookupColor(String color) { 1128 | Map foundColor 1129 | if (color?.startsWith('#')) { 1130 | foundColor = getHexColor(color) 1131 | foundColor.name = color 1132 | return foundColor 1133 | } 1134 | 1135 | if (color == "random") { 1136 | foundColor = pickRandomColor() 1137 | } else { 1138 | foundColor = fullColorMap.find { (it.name as String).equalsIgnoreCase(color) } 1139 | if (!foundColor) { 1140 | throw new RuntimeException("No color found for $color") 1141 | } 1142 | } 1143 | 1144 | return transformNamedColor(foundColor) 1145 | } 1146 | 1147 | private Map transformNamedColor(Map foundColor) { 1148 | Map theColor = foundColor.hsv as Map 1149 | theColor.name = foundColor.name 1150 | theColor 1151 | } 1152 | 1153 | private Map getHexColor(String color) { 1154 | Map rgb = hexToColor color 1155 | rgbToHSV rgb.r, rgb.g, rgb.b, 'high' 1156 | } 1157 | 1158 | private Map expandRgb(Map colorDef) { 1159 | Map rgb = hexToColor(colorDef.rgb) 1160 | Map hsv = rgbToHSV rgb.r, rgb.g, rgb.b, 'high' 1161 | [name: colorDef.name, rgb: colorDef.rgb, rgbMap: rgb, hsv: hsv] 1162 | } 1163 | 1164 | private Map pickRandomColor() { 1165 | def colors = fullColorMap 1166 | def tempRandom = Math.abs(new Random().nextInt() % colors.size()) 1167 | colors[tempRandom] 1168 | } 1169 | 1170 | private List colorList(String sortOrder) { 1171 | if (!(!sortOrder || '0' == sortOrder)) { 1172 | switch (sortOrder) { 1173 | case '1': 1174 | List colorMapHSV = fullColorMap 1175 | colorMapHSV.sort { a, b -> compareHSV(a.hsv, b.hsv) } 1176 | return colorMapHSV 1177 | case '2': 1178 | List colorMapHSV = fullColorMap 1179 | colorMapHSV.sort { a, b -> compareVHS(a.hsv, b.hsv) } 1180 | return colorMapHSV 1181 | case '3': 1182 | List colorMapRGB = fullColorMap 1183 | colorMapRGB.sort { a, b -> compareRGB(b.rgbMap, a.rgbMap) } 1184 | return colorMapRGB 1185 | } 1186 | } 1187 | colorMap 1188 | } 1189 | 1190 | private int compareRGB(Map a, Map b) { 1191 | def result = (a.r as short).compareTo(b.r as short) 1192 | if (0 == result) { 1193 | result = (a.g as short).compareTo(b.g as short) 1194 | } 1195 | if (0 == result) { 1196 | result = (a.b as short).compareTo(b.b as short) 1197 | } 1198 | result 1199 | } 1200 | 1201 | private int compareHSV(Map a, Map b) { 1202 | def result = (a.h as float).compareTo(b.h as float) 1203 | if (0 == result) { 1204 | result = (a.s as float).compareTo(b.s as float) 1205 | } 1206 | if (0 == result) { 1207 | result = (a.v as float).compareTo(b.v as float) 1208 | } 1209 | result 1210 | } 1211 | 1212 | private int compareVHS(Map a, Map b) { 1213 | def result = (a.v as float).compareTo(b.v as float) 1214 | if (0 == result) { 1215 | result = (a.h as float).compareTo(b.h as float) 1216 | } 1217 | if (0 == result) { 1218 | result = (a.s as float).compareTo(b.s as float) 1219 | } 1220 | result 1221 | } 1222 | 1223 | private Map buildColorMaps(String jsonString) { 1224 | def slurper = new JsonSlurper() 1225 | Map map = slurper.parseText jsonString 1226 | Map result = [:] 1227 | map.each { 1228 | key, value -> 1229 | result[key] = getScaledColorMap transformColorValue(value) 1230 | } 1231 | result 1232 | } 1233 | 1234 | private Map buildColorMaps(Map map) { 1235 | Map result = [:] 1236 | map.each { 1237 | key, value -> 1238 | result[key] = getScaledColorMap transformColorValue(value) 1239 | } 1240 | result 1241 | } 1242 | 1243 | private Map transformColorValue(String value) { 1244 | transformColorValue(lookupColor(value)) 1245 | } 1246 | 1247 | private Map transformColorValue(Map hsv) { 1248 | [hue: hsv.h, saturation: hsv.s, brightness: hsv.v] 1249 | } 1250 | 1251 | private List makeColorMaps(Map namedColors, String descriptor) { 1252 | List result = [] 1253 | def darkPixel = [hue: 0, saturation: 0, brightness: 0, kelvin: 0] 1254 | descriptor.findAll(~/(\w+):(\d+)/) { 1255 | section -> 1256 | String name = section[1] 1257 | Integer count = section[2].toInteger() 1258 | def color = namedColors[name] 1259 | 1.upto(count) { 1260 | result << color ?: darkPixel 1261 | } 1262 | } 1263 | result 1264 | } 1265 | 1266 | private Map deviceSetHSBKAndPower(com.hubitat.app.DeviceWrapper device, Number duration, Map hsbkMap, boolean displayed, String power = 'on') { 1267 | def actions = makeActions() 1268 | logDebug("deviceSetHSBKAndPower: $hsbkMap") 1269 | if (hsbkMap) { 1270 | actions.commands << makeCommand('LIGHT.SET_COLOR', [color: hsbkMap, duration: (hsbkMap.duration ?: 0) * 1000]) 1271 | actions.events = actions.events + makeColorMapEvents(hsbkMap, displayed) 1272 | } 1273 | 1274 | if (null != power && device.currentSwitch != power) { 1275 | def powerLevel = 'on' == power ? 65535 : 0 1276 | actions.commands << makeCommand('LIGHT.SET_POWER', [powerLevel: powerLevel, duration: duration * 1000]) 1277 | actions.events << [name: "switch", value: power, displayed: displayed, data: [syncing: "false"]] 1278 | } 1279 | 1280 | actions 1281 | } 1282 | 1283 | private List makeColorMapEvents(Map hsbkMap, Boolean displayed) { 1284 | List events = [] 1285 | if (hsbkMap.hue || hsbkMap.saturation) { 1286 | events << [name: 'colorMode', value: 'RGB', displayed: displayed] 1287 | hsbkMap.hue ? events << [name: 'hue', value: intScaleDown100(hsbkMap.hue), displayed: displayed] : null 1288 | hsbkMap.saturation ? events << [name: 'saturation', value: intScaleDown100(hsbkMap.saturation), displayed: displayed] : null 1289 | hsbkMap.brightness ? events << [name: 'level', value: intScaleDown100(hsbkMap.brightness), displayed: displayed] : null 1290 | events << [name: 'RGB', value: hsbkMap.RGB ?: hsvToRgbString(intScaleDown100(hsbkMap.hue), intScaleDown100(hsbkMap.saturation), intScaleDown100(hsbkMap.brightness)), displayed: displayed] 1291 | } else if (hsbkMap.kelvin) { 1292 | events << [name: 'colorMode', value: 'CT', displayed: displayed] 1293 | events << [name: 'colorTemperature', value: hsbkMap.kelvin as Integer, displayed: displayed] 1294 | hsbkMap.brightness ? events << [name: 'level', value: intScaleDown100(hsbkMap.brightness), displayed: displayed] : null 1295 | } 1296 | 1297 | events << [name: 'colorName', value: hsbkMap.name ?: 'Unknown', displayed: displayed] 1298 | events 1299 | } 1300 | 1301 | private Map getScaledColorMap(Map colorMap) { 1302 | def result = [:] 1303 | def brightness = colorMap.level ?: colorMap.brightness 1304 | 1305 | colorMap.hue instanceof Integer ? result.hue = scaleUp100(colorMap.hue) as Integer : null 1306 | colorMap.saturation instanceof Integer ? result.saturation = scaleUp100(colorMap.saturation) as Integer : null 1307 | brightness instanceof Integer ? result.brightness = scaleUp100(brightness) as Integer : null 1308 | colorMap.kelvin instanceof Integer ? result.kelvin = colorMap.kelvin : null 1309 | logDebug(result) 1310 | result 1311 | } 1312 | 1313 | private Map makeCommand(String command, Map payload) { 1314 | [cmd: command, payload: payload] 1315 | } 1316 | 1317 | private Map makeActions() { 1318 | [commands: [], events: []] 1319 | } 1320 | 1321 | private Map getCurrentHSBK(com.hubitat.app.DeviceWrapper theDevice) { 1322 | [ 1323 | hue : scaleUp(theDevice.currentHue ?: 0, 100), 1324 | saturation: scaleUp(theDevice.currentSaturation ?: 0, 100), 1325 | brightness: scaleUp(theDevice.currentLevel as Integer ?: 0, 100), 1326 | kelvin : theDevice.currentcolorTemperature 1327 | ] 1328 | } 1329 | 1330 | /** Scaling */ 1331 | private Float scaleDown100(value) { 1332 | scaleDown(value, 100) 1333 | } 1334 | 1335 | private Integer scaleUp100(value) { 1336 | scaleUp(value, 100) as Integer 1337 | } 1338 | 1339 | private Float scaleDown(value, maxValue) { 1340 | Float result = ((value * maxValue) / 65535) 1341 | result.round(2) 1342 | } 1343 | 1344 | private Integer intScaleDown100(value) { 1345 | return scaleDown100(value) as Integer 1346 | } 1347 | 1348 | private Long scaleUp(value, maxValue) { 1349 | (value * 65535) / maxValue 1350 | } 1351 | 1352 | private Map parseHeader(Map deviceParams) { 1353 | List headerDescriptor = makeDescriptor('size:w,misc:w,source:i,target:ba8,frame_reserved:ba6,flags:b,sequence:b,protocol_reserved:ba8,type:w,protocol_reserved2:w') 1354 | parseBytes(headerDescriptor, (hubitat.helper.HexUtils.hexStringToIntArray(deviceParams.payload) as List).each { 1355 | it & 0xff 1356 | }) 1357 | } 1358 | 1359 | private void createDeviceDefinition(Map parsed, String ip, String mac) { 1360 | List stateVersionDescriptor = makeDescriptor('vendor:i,product:i,version:i') 1361 | def version = parseBytes stateVersionDescriptor, parsed.remainder as List 1362 | def device = deviceVersion version 1363 | device['ip'] = ip 1364 | device['mac'] = mac 1365 | saveDeviceDefinition device 1366 | } 1367 | 1368 | private Map getDeviceDefinition(String mac) { 1369 | Map devices = getDeviceDefinitions() 1370 | 1371 | devices[mac] 1372 | } 1373 | 1374 | private void clearDeviceDefinitions() { 1375 | atomicState.devices = [:] 1376 | } 1377 | 1378 | public Map getDeviceDefinitions() { 1379 | if (atomicState.devices == null) { 1380 | atomicState.devices = [:] 1381 | } 1382 | 1383 | atomicState.devices 1384 | } 1385 | 1386 | private void saveDeviceDefinitions(Map devices) { 1387 | atomicState.devices = devices 1388 | } 1389 | 1390 | private void saveDeviceDefinition(Map device) { 1391 | Map devices = getDeviceDefinitions() 1392 | 1393 | devices[device.mac] = device 1394 | 1395 | saveDeviceDefinitions devices 1396 | } 1397 | 1398 | private void deleteDeviceDefinition(Map device) { 1399 | Map devices = getDeviceDefinitions() 1400 | 1401 | devices.remove device.mac 1402 | 1403 | saveDeviceDefinitions devices 1404 | } 1405 | 1406 | private String updateDeviceDefinition(String mac, String ip, Map properties) { 1407 | Map device = getDeviceDefinition mac 1408 | if (!device) { 1409 | // perhaps it's a real device? 1410 | return getChildDevice(ip) 1411 | } 1412 | properties.each { key, val -> (device[key] = val) } 1413 | 1414 | isDeviceComplete(device) ? makeRealDevice(device) : saveDeviceDefinition(device) 1415 | null 1416 | } 1417 | 1418 | private List knownDeviceLabels() { 1419 | getKnownIps().values().each { officialDeviceName(it.label) }.asList() 1420 | } 1421 | 1422 | private void makeRealDevice(Map device) { 1423 | addToKnownIps device 1424 | try { 1425 | addChildDevice( 1426 | 'robheyes', 1427 | device.deviceName, 1428 | device.ip, 1429 | null, 1430 | [ 1431 | group : device.group, 1432 | label : device.label, 1433 | location: device.location 1434 | ] 1435 | ) 1436 | addToKnownIps device 1437 | updateKnownDevices() 1438 | logInfo "Added device $device.label of type $device.deviceName with ip address $device.ip and MAC address $device.mac" 1439 | } catch (com.hubitat.app.exception.UnknownDeviceTypeException e) { 1440 | logWarn "${e.message} - you need to install the appropriate driver" 1441 | device.error = "No driver installed for $device.deviceName" 1442 | addToKnownIps(device) 1443 | } catch (IllegalArgumentException ignored) { 1444 | // Intentionally ignored. Expected if device already present 1445 | } 1446 | deleteDeviceDefinition device 1447 | } 1448 | 1449 | private String officialDeviceName(String name) { 1450 | return deviceNamePrefix() + name 1451 | } 1452 | 1453 | private void addToKnownIps(Map device) { 1454 | def knownIps = getKnownIps() 1455 | knownIps[device.ip as String] = device 1456 | atomicState.knownIps = knownIps 1457 | } 1458 | 1459 | private void clearKnownIps() { 1460 | atomicState.knownIps = [:] 1461 | } 1462 | 1463 | private void clearKnownIpsWithErrors() { 1464 | Map ips = atomicState.knownIps 1465 | ips = ips.findAll { 1466 | k, v -> 1467 | !v.containsKey('error') 1468 | } 1469 | atomicState.knownIps = ips 1470 | } 1471 | 1472 | private Map getKnownIps() { 1473 | atomicState.knownIps ?: [:] 1474 | } 1475 | 1476 | private Boolean isDeviceComplete(Map device) { 1477 | List missing = matchKeys device, ['ip', 'mac', 'group', 'label', 'location'] 1478 | missing.isEmpty() 1479 | } 1480 | 1481 | private List matchKeys(Map device, List expected) { 1482 | def result = [] 1483 | expected.each { 1484 | if (!device.containsKey(it)) { 1485 | result << it 1486 | } 1487 | } 1488 | result 1489 | } 1490 | 1491 | private String convertIpLong(String ip) { 1492 | sprintf '%d.%d.%d.%d', hubitat.helper.HexUtils.hexStringToIntArray(ip) 1493 | } 1494 | 1495 | private String applySubscript(String descriptor, Number subscript) { 1496 | descriptor.replace('!', subscript.toString()) 1497 | } 1498 | 1499 | 1500 | private Map deviceVersion(Map device) { 1501 | switch (device.product) { 1502 | case 1: 1503 | return [ 1504 | name : 'Original 1000', 1505 | deviceName: 'LIFX Color', 1506 | features : [ 1507 | color : true, 1508 | infrared : false, 1509 | multizone : false, 1510 | temperature_range: [min: 2500, max: 9000], 1511 | chain : false 1512 | ] 1513 | ] 1514 | case 3: 1515 | return [ 1516 | name : 'Color 650', 1517 | deviceName: 'LIFX Color', 1518 | features : [ 1519 | color : true, 1520 | infrared : false, 1521 | multizone : false, 1522 | temperature_range: [min: 2500, max: 9000], 1523 | chain : false 1524 | ] 1525 | ] 1526 | case 10: 1527 | return [ 1528 | name : 'White 800 (Low Voltage)', 1529 | deviceName: 'LIFX White', 1530 | features : [ 1531 | color : false, 1532 | infrared : false, 1533 | multizone : false, 1534 | temperature_range: [min: 2700, max: 6500], 1535 | chain : false 1536 | ] 1537 | ] 1538 | case 11: 1539 | case 19: 1540 | return [ 1541 | name : 'White 800 (High Voltage)', 1542 | deviceName: 'LIFX White', 1543 | features : [ 1544 | color : false, 1545 | infrared : false, 1546 | multizone : false, 1547 | temperature_range: [min: 2700, max: 6500], 1548 | chain : false 1549 | ] 1550 | ] 1551 | case 18: 1552 | return [ 1553 | name : 'White 900 BR30 (Low Voltage)', 1554 | deviceName: 'LIFX White', 1555 | features : [ 1556 | color : false, 1557 | infrared : false, 1558 | multizone : false, 1559 | temperature_range: [min: 2700, max: 6500], 1560 | chain : false 1561 | ] 1562 | ] 1563 | case 20: 1564 | return [ 1565 | name : 'Color 1000 BR30', 1566 | deviceName: 'LIFX Color', 1567 | features : [ 1568 | color : true, 1569 | infrared : false, 1570 | multizone : false, 1571 | temperature_range: [min: 2500, max: 9000], 1572 | chain : false 1573 | ] 1574 | ] 1575 | case 22: 1576 | return [ 1577 | name : 'Color 1000', 1578 | deviceName: 'LIFX Color', 1579 | features : [ 1580 | color : true, 1581 | infrared : false, 1582 | multizone : false, 1583 | temperature_range: [min: 2500, max: 9000], 1584 | chain : false 1585 | ] 1586 | ] 1587 | case 27: 1588 | case 43: 1589 | case 62: 1590 | case 91: 1591 | case 92: 1592 | case 93: 1593 | case 97: 1594 | return [ 1595 | name : 'LIFX A19', 1596 | deviceName: 'LIFX Color', 1597 | features : [ 1598 | color : true, 1599 | infrared : false, 1600 | multizone : false, 1601 | temperature_range: [min: 2500, max: 9000], 1602 | chain : false 1603 | ] 1604 | ] 1605 | case 28: 1606 | case 44: 1607 | case 63: 1608 | case 94: 1609 | case 98: 1610 | return [ 1611 | name : 'LIFX BR30', 1612 | deviceName: 'LIFX Color', 1613 | features : [ 1614 | color : true, 1615 | infrared : false, 1616 | multizone : false, 1617 | temperature_range: [min: 2500, max: 9000], 1618 | chain : false 1619 | ] 1620 | ] 1621 | case 29: 1622 | case 45: 1623 | case 64: 1624 | case 109: 1625 | case 111: 1626 | return [ 1627 | name : 'LIFX+ A19', 1628 | deviceName: 'LIFXPlus Color', 1629 | features : [ 1630 | color : true, 1631 | infrared : true, 1632 | multizone : false, 1633 | temperature_range: [min: 2500, max: 9000], 1634 | chain : false 1635 | ] 1636 | ] 1637 | case 30: 1638 | case 46: 1639 | case 65: 1640 | case 110: 1641 | return [ 1642 | name : 'LIFX+ BR30', 1643 | deviceName: 'LIFXPlus Color', 1644 | features : [ 1645 | color : true, 1646 | infrared : true, 1647 | multizone : false, 1648 | temperature_range: [min: 2500, max: 9000], 1649 | chain : false 1650 | ] 1651 | ] 1652 | case 31: 1653 | return [ 1654 | name : 'LIFX Z', 1655 | deviceName: 'LIFX Multizone', 1656 | features : [ 1657 | color : true, 1658 | infrared : false, 1659 | multizone : true, 1660 | temperature_range: [min: 2500, max: 9000], 1661 | chain : false 1662 | ] 1663 | ] 1664 | case 32: 1665 | return [ 1666 | name : 'LIFX Z 2', 1667 | deviceName: 'LIFX Multizone', 1668 | features : [ 1669 | color : true, 1670 | infrared : false, 1671 | multizone : true, 1672 | temperature_range : [min: 2500, max: 9000], 1673 | chain : false, 1674 | min_ext_mz_firmware: 1532997580 1675 | ] 1676 | ] 1677 | case 36: 1678 | case 37: 1679 | case 40: 1680 | return [ 1681 | name : 'LIFX Downlight', 1682 | deviceName: 'LIFX Color', 1683 | features : [ 1684 | color : true, 1685 | infrared : false, 1686 | multizone : false, 1687 | temperature_range: [min: 2500, max: 9000], 1688 | chain : false 1689 | ] 1690 | ] 1691 | case 38: 1692 | return [ 1693 | name : 'LIFX Beam', 1694 | deviceName: 'LIFX Multizone', 1695 | features : [ 1696 | color : true, 1697 | infrared : false, 1698 | multizone : true, 1699 | temperature_range : [min: 2500, max: 9000], 1700 | chain : false, 1701 | min_ext_mz_firmware: 1532997580 1702 | ] 1703 | ] 1704 | case 39: 1705 | return [ 1706 | name : 'LIFX Downlight white to warm', 1707 | deviceName: 'LIFX Day and Dusk', 1708 | features : [ 1709 | color : false, 1710 | chain : false, 1711 | infrared : false, 1712 | multizone : false, 1713 | temperature_range: [min: 1500, max: 9000] 1714 | ] 1715 | ] 1716 | case 49: 1717 | case 59: 1718 | return [ 1719 | name : 'LIFX Mini Color', 1720 | deviceName: 'LIFX Color', 1721 | features : [ 1722 | color : true, 1723 | infrared : false, 1724 | multizone : false, 1725 | temperature_range: [min: 2500, max: 9000], 1726 | chain : false 1727 | ] 1728 | ] 1729 | case 50: 1730 | case 60: 1731 | return [ 1732 | name : 'LIFX Mini Day and Dusk', 1733 | deviceName: 'LIFX Day and Dusk', 1734 | features : [ 1735 | color : false, 1736 | infrared : false, 1737 | multizone : false, 1738 | temperature_range: [min: 1500, max: 4000], 1739 | chain : false 1740 | ] 1741 | ] 1742 | case 51: 1743 | case 61: 1744 | case 66: 1745 | case 87: 1746 | case 88: 1747 | return [ 1748 | name : 'LIFX Mini White', 1749 | deviceName: 'LIFX White Mono', 1750 | features : [ 1751 | color : false, 1752 | infrared : false, 1753 | multizone : false, 1754 | temperature_range: [min: 2700, max: 2700], 1755 | chain : false 1756 | ] 1757 | ] 1758 | case 52: 1759 | return [ 1760 | name : 'LIFX GU10', 1761 | deviceName: 'LIFX Color', 1762 | features : [ 1763 | color : true, 1764 | infrared : false, 1765 | multizone : false, 1766 | temperature_range: [min: 2500, max: 9000], 1767 | chain : false 1768 | ] 1769 | ] 1770 | case 55: 1771 | return [ 1772 | name : 'LIFX Tile', 1773 | deviceName: 'LIFX Tile', 1774 | features : [ 1775 | color : true, 1776 | infrared : false, 1777 | multizone : false, 1778 | temperature_range: [min: 1500, max: 9000], 1779 | chain : true 1780 | ] 1781 | ] 1782 | case 56: 1783 | return [ 1784 | name : 'LIFX Beam', 1785 | deviceName: 'LIFX Multizone', 1786 | features : [ 1787 | color : true, 1788 | infrared : false, 1789 | multizone : true, 1790 | temperature_range: [min: 2500, max: 9000], 1791 | chain : false, 1792 | ] 1793 | ] 1794 | case 81: 1795 | case 96: 1796 | return [ 1797 | name : 'LIFX Candle Warm to White', 1798 | deviceName: 'LIFX Day and Dusk', 1799 | features : [ 1800 | color : false, 1801 | infrared : false, 1802 | multizone : false, 1803 | temperature_range: [min: 2200, max: 6500], 1804 | chain : false 1805 | ] 1806 | ] 1807 | case 82: 1808 | case 100: 1809 | return [ 1810 | name : 'LIFX Filament Clear', 1811 | deviceName: 'LIFX White Mono', 1812 | features : [ 1813 | color : false, 1814 | infrared : false, 1815 | multizone : false, 1816 | temperature_range: [min: 2100, max: 2100], 1817 | chain : false 1818 | ] 1819 | ] 1820 | case 85: 1821 | case 101: 1822 | return [ 1823 | name : 'LIFX Filament Amber', 1824 | deviceName: 'LIFX White Mono', 1825 | features : [ 1826 | color : false, 1827 | infrared : false, 1828 | multizone : false, 1829 | temperature_range: [min: 2000, max: 2000], 1830 | chain : false 1831 | ] 1832 | ] 1833 | case 57: 1834 | case 68: 1835 | return [ 1836 | name : 'LIFX Candle', 1837 | deviceName: 'LIFX Color', 1838 | features : [ 1839 | color : true, 1840 | infrared : false, 1841 | multizone : false, 1842 | temperature_range: [min: 1500, max: 9000], 1843 | chain : false 1844 | ] 1845 | ] 1846 | // case 96: 1847 | // return [ 1848 | // name : 'LIFX Candle White', 1849 | // deviceName: 'LIFX White Mono', 1850 | // features : [ 1851 | // color : false, 1852 | // infrared : false, 1853 | // multizone : false, 1854 | // temperature_range: [min: 2700, max: 2700], 1855 | // chain : false 1856 | // ] 1857 | // ] 1858 | default: 1859 | return [ 1860 | name: "Unknown LIFX device with product id ${device.product} treating it as LIFX White Mono for now", 1861 | deviceName: 'LIFX Unknown', 1862 | features : [ 1863 | color : false, 1864 | infrared : false, 1865 | multizone : false, 1866 | temperature_range: [min: 2700, max: 2700], 1867 | chain : false 1868 | ] 1869 | ] 1870 | } 1871 | } 1872 | 1873 | /** Color related */ 1874 | private Map rgbToHSV(red = 255, green = 255, blue = 255, resolution = "low") { 1875 | // Takes RGB (0-255) and returns HSV in 0-360, 0-100, 0-100 1876 | // resolution ("low", "high") will return a hue between 0-100, or 0-360, respectively. 1877 | List hsv = hubitat.helper.ColorUtils.rgbToHSV([red, green, blue]) 1878 | def hsvMap = [h: hsv[0] * (resolution == 'high' ? 3.6d : 1d), s: hsv[1], v: hsv[2]] 1879 | return hsvMap 1880 | } 1881 | 1882 | private String hsvToRgbString(hue, saturation, level) { 1883 | def rgb = hubitat.helper.ColorUtils.hsvToRGB([hue, saturation, level]) 1884 | return hubitat.helper.ColorUtils.rgbToHEX(rgb) 1885 | } 1886 | 1887 | private Map hexToColor(String hex) { 1888 | List rgbList = hubitat.helper.ColorUtils.hexToRGB(hex) 1889 | return [r: rgbList[0], g: rgbList[1], b: rgbList[2]] 1890 | } 1891 | 1892 | /** Device parsing */ 1893 | private Map parseDeviceParameters(String description) { 1894 | def deviceParams = [:] 1895 | description.findAll(~/(\w+):(\w+)/) { 1896 | (deviceParams[it[1]] = it[2]) 1897 | } 1898 | deviceParams 1899 | } 1900 | 1901 | private Map parseHeaderFromDescription(String description) { 1902 | parseHeader parseDeviceParameters(description) 1903 | } 1904 | 1905 | private Map parsePayload(String deviceAndType, Map header) { 1906 | parseBytes descriptors[deviceAndType], getRemainder(header) 1907 | } 1908 | 1909 | private Map parseBytes(String descriptor, List bytes) { 1910 | parseBytes makeDescriptor(descriptor), bytes 1911 | } 1912 | 1913 | private Map parseBytes(List descriptor, List bytes) { 1914 | Map result = new HashMap() 1915 | int offset = 0 1916 | for (item in descriptor) { 1917 | String kind = item.kind 1918 | // partition the data 1919 | int totalLength = item.size * item.count 1920 | int nextOffset = offset + totalLength 1921 | List data = bytes.subList offset, nextOffset 1922 | assert (data.size() <= totalLength) 1923 | 1924 | if (item.isArray) { 1925 | def itemSize = item.size as Number 1926 | def numItems = data.size().intdiv(itemSize) 1927 | nextOffset = offset + numItems * item.size // NB this only works if the variable length array is at the end 1928 | def subMap = [:] 1929 | for (int i = 0; i < numItems; i++) { 1930 | def startOffset = i * itemSize 1931 | def endOffset = (i + 1) * itemSize 1932 | processSegment subMap, data.subList(startOffset, endOffset), item, i, true 1933 | } 1934 | result.put item.name, subMap 1935 | } else { 1936 | processSegment result, data, item, item.name 1937 | } 1938 | offset = nextOffset 1939 | } 1940 | if (offset < bytes.size()) { 1941 | result.put 'remainder', bytes[offset..-1] 1942 | } 1943 | return result 1944 | } 1945 | 1946 | private void processSegment(Map result, List data, Map item, name, boolean logIt = false) { 1947 | 1948 | switch (item.kind) { 1949 | case 'B': 1950 | case 'W': 1951 | case 'I': 1952 | case 'L': 1953 | data = data.reverse() 1954 | storeValue result, data, item.size, name 1955 | break 1956 | case 'F': 1957 | data = data.reverse() 1958 | Long value = 0 1959 | data.each { value = (value * 256) + it } 1960 | def theFloat = Float.intBitsToFloat(value) 1961 | result.put name, theFloat 1962 | break 1963 | case 'T': 1964 | result.put name, new String((data.findAll { it != 0 }) as byte[]) 1965 | break 1966 | case 'H': 1967 | Map color = parseBytes 'hue:w;saturation:w;brightness:w,kelvin:w', data 1968 | result.put name, color 1969 | break 1970 | default: 1971 | throw new RuntimeException("Unexpected item kind '$kind'") 1972 | } 1973 | } 1974 | 1975 | private void storeValue(Map result, List data, numBytes, index, boolean trace = false) { 1976 | BigInteger value = 0 1977 | data.each { value = (value * 256) + it } 1978 | def theValue 1979 | switch (numBytes) { 1980 | case 1: 1981 | theValue = (value & 0xFF) as long 1982 | break 1983 | case 2: 1984 | theValue = (value & 0xFFFF) as long 1985 | break 1986 | case 3: case 4: 1987 | theValue = (value & 0xFFFFFFFF) as long 1988 | break 1989 | default: // this should complain if longer than 8 bytes 1990 | theValue = (value & 0xFFFFFFFFFFFFFFFF) as long 1991 | } 1992 | 1993 | result.put index, theValue 1994 | } 1995 | 1996 | private List makePayload(String deviceAndType, Map payload) { 1997 | def descriptor = makeDescriptor(descriptors[deviceAndType]) 1998 | def result = [] 1999 | descriptor.each { 2000 | Map item -> 2001 | if ('H' == item.kind) { 2002 | if (item.isArray) { 2003 | for (int i = 0; i < item.count; i++) { 2004 | Map hsbk = payload.colors[i] as Map 2005 | add result, (hsbk?.hue ?: 0) as short 2006 | add result, (hsbk?.saturation ?: 0) as short 2007 | add result, (hsbk?.brightness ?: 0) as short 2008 | add result, (hsbk?.kelvin ?: 0) as short 2009 | } 2010 | } else { 2011 | add result, (payload.color['hue'] ?: 0) as short 2012 | add result, (payload.color['saturation'] ?: 0) as short 2013 | add result, (payload.color['brightness'] ?: 0) as short 2014 | add result, (payload.color['kelvin'] ?: 0) as short 2015 | } 2016 | return 2017 | } 2018 | def value = payload[item.name] ?: 0 2019 | //TODO possibly extend this to the other types A,S & B 2020 | if ('F' == item.kind) { 2021 | add result, Float.floatToIntBits(value) 2022 | return 2023 | } 2024 | switch (item.size as int) { 2025 | case 1: 2026 | add result, value as byte 2027 | break 2028 | case 2: 2029 | add result, value as short 2030 | break 2031 | case 3: case 4: 2032 | if (item.isArray) { 2033 | for (int i = 0; i < item.count; i++) { 2034 | add result, value[i] as int 2035 | } 2036 | } else { 2037 | add result, value as int 2038 | } 2039 | break 2040 | default: // this should complain if longer than 8 bytes 2041 | add result, value as long 2042 | } 2043 | } 2044 | result as List 2045 | } 2046 | 2047 | private List getRemainder(header) { header.remainder as List } 2048 | 2049 | private Number itemLength(String kind) { 2050 | switch (kind) { 2051 | case 'B': return 1 2052 | case 'W': return 2 2053 | case 'I': return 4 2054 | case 'L': return 8 2055 | case 'H': return 8 2056 | case 'F': return 4 2057 | case 'T': return 1 // length of character 2058 | default: 2059 | throw new RuntimeException("Unexpected item kind '$kind'") 2060 | } 2061 | } 2062 | 2063 | private List makeDescriptor(String desc) { 2064 | desc.findAll(~/(\w+):([bBwWiIlLhHfFtT][aA]?)(\d+)?/) { 2065 | full -> 2066 | def theKind = full[2].toUpperCase() 2067 | def baseKind = theKind[0] 2068 | def isArray = theKind.length() > 1 && theKind[1] == 'A' 2069 | [ 2070 | name : full[1], 2071 | kind : baseKind, 2072 | isArray: isArray, 2073 | count : full[3]?.toInteger() ?: 1, 2074 | size : itemLength(baseKind) 2075 | ] 2076 | } 2077 | } 2078 | 2079 | private String getHubIP() { 2080 | def hub = location.hubs[0] 2081 | 2082 | hub.localIP 2083 | } 2084 | 2085 | private void clearBufferCache() { 2086 | atomicState.bufferCache = [:] 2087 | } 2088 | 2089 | private List lookupBuffer(String hashKey) { 2090 | def cache = getBufferCache() 2091 | cache[hashKey] 2092 | } 2093 | 2094 | private Map getBufferCache() { 2095 | atomicState.bufferCache ?: [:] 2096 | } 2097 | 2098 | private void storeBuffer(String hashKey, List buffer) { 2099 | def cache = getBufferCache() 2100 | cache[hashKey] = buffer 2101 | atomicState.bufferCache = cache 2102 | } 2103 | 2104 | private Byte sequenceNumber() { 2105 | atomicState.sequence = ((atomicState.sequence ?: 0) + 1) % 128 2106 | } 2107 | 2108 | /** Protocol packet building */ 2109 | 2110 | private def createFrame(List buffer, boolean tagged) { 2111 | add buffer, 0 as short 2112 | add buffer, 0x00 as byte 2113 | add buffer, (tagged ? 0x34 : 0x14) as byte 2114 | add buffer, lifxSource() 2115 | } 2116 | 2117 | private int lifxSource() { 2118 | 0x48454C44 // = HELD: Hubitat Elevation LIFX Device :) 2119 | } 2120 | 2121 | private def createFrameAddress(List buffer, byte[] target, boolean ackRequired, boolean responseRequired, Byte sequenceNumber) { 2122 | add buffer, target 2123 | add buffer, 0 as short 2124 | fill buffer, 0 as byte, 6 2125 | add buffer, ((ackRequired ? 0x02 : 0) | (responseRequired ? 0x01 : 0)) as byte 2126 | add buffer, sequenceNumber as byte 2127 | } 2128 | 2129 | private def createProtocolHeader(List buffer, short messageType) { 2130 | fill buffer, 0 as byte, 8 2131 | add buffer, messageType 2132 | add buffer, 0 as short 2133 | } 2134 | 2135 | private def createPayload(List buffer, byte[] payload) { 2136 | add buffer, payload 2137 | } 2138 | 2139 | /** LOW LEVEL BUFFER FILLING */ 2140 | private void add(List buffer, byte value) { 2141 | buffer.add Byte.toUnsignedInt(value) 2142 | } 2143 | 2144 | private void add(List buffer, short value) { 2145 | def lower = value & 0xff 2146 | add buffer, lower as byte 2147 | add buffer, ((value - lower) >>> 8) as byte 2148 | } 2149 | 2150 | private void add(List buffer, int value) { 2151 | def lower = value & 0xffff 2152 | add buffer, lower as short 2153 | add buffer, Integer.divideUnsigned(value - lower, 0x10000) as short 2154 | } 2155 | 2156 | private void add(List buffer, long value) { 2157 | def lower = value & 0xffffffff 2158 | add buffer, lower as int 2159 | add buffer, Long.divideUnsigned(value - lower, 0x100000000) as int 2160 | } 2161 | 2162 | private void add(List buffer, byte[] values) { 2163 | for (value in values) { 2164 | add buffer, value 2165 | } 2166 | } 2167 | 2168 | private void add(List buffer, List other) { 2169 | for (value in other) { 2170 | add buffer, value 2171 | } 2172 | } 2173 | 2174 | private void fill(List buffer, byte value, int count) { 2175 | for (int i = 0; i < count; i++) { 2176 | add buffer, value 2177 | } 2178 | } 2179 | 2180 | private void put(List buffer, int index, byte value) { 2181 | buffer.set index, Byte.toUnsignedInt(value) 2182 | } 2183 | 2184 | private void put(List buffer, int index, short value) { 2185 | def lower = value & 0xff 2186 | put buffer, index, lower as byte 2187 | put buffer, index + 1, ((value - lower) >>> 8) as byte 2188 | } 2189 | 2190 | /** LOGGING **/ 2191 | private void logDebug(msg) { 2192 | log.debug msg 2193 | } 2194 | 2195 | private void logInfo(msg) { 2196 | log.info msg 2197 | } 2198 | 2199 | private void logWarn(String msg) { 2200 | log.warn msg 2201 | } 2202 | 2203 | private List getFullColorMap() { 2204 | colorMap.collect { expandRgb it } 2205 | } 2206 | 2207 | @Lazy @Field List fullColorMap = getFullColorMap() 2208 | 2209 | /** Many of these colours are taken from https://encycolorpedia.com/named */ 2210 | @Field static final List colorMap = 2211 | [ 2212 | [name: 'Absolute Zero', rgb: '#0048BA'], 2213 | [name: 'Acajou', rgb: '#4C2F27'], 2214 | [name: 'Acid Green', rgb: '#B0BF1A'], 2215 | [name: 'Aero', rgb: '#7CB9E8'], 2216 | [name: 'Aero Blue', rgb: '#C9FFE5'], 2217 | [name: 'African Violet', rgb: '#B284BE'], 2218 | [name: 'Air Superiority Blue', rgb: '#72A0C1'], 2219 | [name: 'Alabama Crimson', rgb: '#AF002A'], 2220 | [name: 'Alabaster', rgb: '#F2F0E6'], 2221 | [name: 'Alice Blue', rgb: '#F0F8FF'], 2222 | [name: 'Alizarin Crimson', rgb: '#E32636'], 2223 | [name: 'Alloy Orange', rgb: '#C46210'], 2224 | [name: 'Almond', rgb: '#EFDECD'], 2225 | [name: 'Aloeswood Brown', rgb: '#5A6457'], 2226 | [name: 'Aloewood Color', rgb: '#6A432D'], 2227 | [name: 'Aluminum', rgb: '#D6D6D6'], 2228 | [name: 'Aluminum Foil', rgb: '#D2D9DB'], 2229 | [name: 'Amaranth', rgb: '#E52B50'], 2230 | [name: 'Amaranth Deep Purple', rgb: '#9F2B68'], 2231 | [name: 'Amaranth Pink', rgb: '#F19CBB'], 2232 | [name: 'Amaranth Purple', rgb: '#AB274F'], 2233 | [name: 'Amaranth Red', rgb: '#D3212D'], 2234 | [name: 'Amazon', rgb: '#3B7A57'], 2235 | [name: 'Amber', rgb: '#FFBF00'], 2236 | [name: 'Amber (Kohaku-iro)', rgb: '#CA6924'], 2237 | [name: 'Amber (SAE/ECE)', rgb: '#FF7E00'], 2238 | [name: 'American Blue', rgb: '#3B3B6D'], 2239 | [name: 'American Bronze', rgb: '#391802'], 2240 | [name: 'American Brown', rgb: '#804040'], 2241 | [name: 'American Gold', rgb: '#D3AF37'], 2242 | [name: 'American Green', rgb: '#34B334'], 2243 | [name: 'American Orange', rgb: '#FF8B00'], 2244 | [name: 'American Pink', rgb: '#FF9899'], 2245 | [name: 'American Purple', rgb: '#431C53'], 2246 | [name: 'American Red', rgb: '#B32134'], 2247 | [name: 'American Rose', rgb: '#FF033E'], 2248 | [name: 'American Silver', rgb: '#CFCFCF'], 2249 | [name: 'American Violet', rgb: '#551B8C'], 2250 | [name: 'American Yellow', rgb: '#F2B400'], 2251 | [name: 'Amethyst', rgb: '#9966CC'], 2252 | [name: 'Amur Cork Tree', rgb: '#F3C13A'], 2253 | [name: 'Anti-Flash White', rgb: '#F2F3F4'], 2254 | [name: 'Antique Brass', rgb: '#CD9575'], 2255 | [name: 'Antique Bronze', rgb: '#665D1E'], 2256 | [name: 'Antique Fuchsia', rgb: '#915C83'], 2257 | [name: 'Antique Ruby', rgb: '#841B2D'], 2258 | [name: 'Antique White', rgb: '#FAEBD7'], 2259 | [name: 'Apple', rgb: '#66B447'], 2260 | [name: 'Apple Green', rgb: '#8DB600'], 2261 | [name: 'Apricot', rgb: '#FBCEB1'], 2262 | [name: 'Aqua', rgb: '#00FFFF'], 2263 | [name: 'Aqua Blue', rgb: '#86ABA5'], 2264 | [name: 'Aquamarine', rgb: '#7FFFD4'], 2265 | [name: 'Arctic Lime', rgb: '#D0FF14'], 2266 | [name: 'Argent', rgb: '#C0C0C0'], 2267 | [name: 'Army Green', rgb: '#4B5320'], 2268 | [name: 'Artichoke', rgb: '#8F9779'], 2269 | [name: 'Arylide Yellow', rgb: '#E9D66B'], 2270 | [name: 'Asparagus', rgb: '#87A96B'], 2271 | [name: 'Ateneo Blue', rgb: '#003A6C'], 2272 | [name: 'Atomic Tangerine', rgb: '#FF9966'], 2273 | [name: 'Auburn', rgb: '#A52A2A'], 2274 | [name: 'Aureolin', rgb: '#FDEE00'], 2275 | [name: 'Avocado', rgb: '#568203'], 2276 | [name: 'Awesome', rgb: '#FF2052'], 2277 | [name: 'Axolotl', rgb: '#6E7F80'], 2278 | [name: 'Azure', rgb: '#007FFF'], 2279 | [name: 'Azure Mist', rgb: '#F0FFFF'], 2280 | [name: 'Azureish White', rgb: '#DBE9F4'], 2281 | [name: "B'dazzled Blue", rgb: '#2E5894'], 2282 | [name: 'Baby Blue', rgb: '#89CFF0'], 2283 | [name: 'Baby Blue Eyes', rgb: '#A1CAF1'], 2284 | [name: 'Baby Pink', rgb: '#F4C2C2'], 2285 | [name: 'Baby Powder', rgb: '#FEFEFA'], 2286 | [name: 'Baiko Brown', rgb: '#857C55'], 2287 | [name: 'Baker-Miller Pink', rgb: '#FF91AF'], 2288 | [name: 'Ball Blue', rgb: '#21ABCD'], 2289 | [name: 'Banana Mania', rgb: '#FAE7B5'], 2290 | [name: 'Banana Yellow', rgb: '#FFE135'], 2291 | [name: 'Bangladesh Green', rgb: '#006A4E'], 2292 | [name: 'Barbie Pink', rgb: '#E94196'], 2293 | /* omitted lots of other Barbie Pink shades*/ 2294 | [name: 'Barn Red', rgb: '#7C0A02'], 2295 | [name: 'Battery Charged Blue', rgb: '#1DACD6'], 2296 | [name: 'Battleship Grey', rgb: '#848482'], 2297 | [name: 'Bayside', rgb: '#5FC9BF'], 2298 | 2299 | [name: 'Beige', rgb: '#F5F5DC'], 2300 | [name: 'Bisque', rgb: '#FFE4C4'], 2301 | [name: 'Blanched Almond', rgb: '#FFEBCD'], 2302 | [name: 'Blue', rgb: '#0000FF'], 2303 | [name: 'Blue Violet', rgb: '#8A2BE2'], 2304 | [name: 'Brown', rgb: '#A52A2A'], 2305 | [name: 'Burly Wood', rgb: '#DEB887'], 2306 | [name: 'Cadet Blue', rgb: '#5F9EA0'], 2307 | [name: 'Chartreuse', rgb: '#7FFF00'], 2308 | [name: 'Chocolate', rgb: '#D2691E'], 2309 | [name: 'Cool White', rgb: '#F3F6F7'], 2310 | [name: 'Coral', rgb: '#FF7F50'], 2311 | [name: 'Corn Flower Blue', rgb: '#6495ED'], 2312 | [name: 'Corn Silk', rgb: '#FFF8DC'], 2313 | [name: 'Crimson', rgb: '#DC143C'], 2314 | [name: 'Cyan', rgb: '#00FFFF'], 2315 | [name: 'Dark Blue', rgb: '#00008B'], 2316 | [name: 'Dark Cyan', rgb: '#008B8B'], 2317 | [name: 'Dark Golden Rod', rgb: '#B8860B'], 2318 | [name: 'Dark Gray', rgb: '#A9A9A9'], 2319 | [name: 'Dark Green', rgb: '#006400'], 2320 | [name: 'Dark Khaki', rgb: '#BDB76B'], 2321 | [name: 'Dark Magenta', rgb: '#8B008B'], 2322 | [name: 'Dark Olive Green', rgb: '#556B2F'], 2323 | [name: 'Dark Orange', rgb: '#FF8C00'], 2324 | [name: 'Dark Orchid', rgb: '#9932CC'], 2325 | [name: 'Dark Red', rgb: '#8B0000'], 2326 | [name: 'Dark Salmon', rgb: '#E9967A'], 2327 | [name: 'Dark Sea Green', rgb: '#8FBC8F'], 2328 | [name: 'Dark Slate Blue', rgb: '#483D8B'], 2329 | [name: 'Dark Slate Gray', rgb: '#2F4F4F'], 2330 | [name: 'Dark Turquoise', rgb: '#00CED1'], 2331 | [name: 'Dark Violet', rgb: '#9400D3'], 2332 | [name: 'Daylight White', rgb: '#F2F2F2'], 2333 | [name: 'Deep Pink', rgb: '#FF1493'], 2334 | [name: 'Deep Sky Blue', rgb: '#00BFFF'], 2335 | [name: 'Dim Gray', rgb: '#696969'], 2336 | [name: 'Dodger Blue', rgb: '#1E90FF'], 2337 | [name: 'Fire Brick', rgb: '#B22222'], 2338 | [name: 'Floral White', rgb: '#FFFAF0'], 2339 | [name: 'Forest Green', rgb: '#228B22'], 2340 | [name: 'Fuchsia', rgb: '#FF00FF'], 2341 | [name: 'Gainsboro', rgb: '#DCDCDC'], 2342 | [name: 'Ghost White', rgb: '#F8F8FF'], 2343 | [name: 'Gold', rgb: '#FFD700'], 2344 | [name: 'Golden Rod', rgb: '#DAA520'], 2345 | [name: 'Gray', rgb: '#808080'], 2346 | [name: 'Green', rgb: '#008000'], 2347 | [name: 'Green Yellow', rgb: '#ADFF2F'], 2348 | [name: 'Honeydew', rgb: '#F0FFF0'], 2349 | [name: 'Hot Pink', rgb: '#FF69B4'], 2350 | [name: 'Indian Red', rgb: '#CD5C5C'], 2351 | [name: 'Indigo', rgb: '#4B0082'], 2352 | [name: 'Ivory', rgb: '#FFFFF0'], 2353 | [name: 'Khaki', rgb: '#F0E68C'], 2354 | [name: 'Lavender', rgb: '#E6E6FA'], 2355 | [name: 'Lavender Blush', rgb: '#FFF0F5'], 2356 | [name: 'Lawn Green', rgb: '#7CFC00'], 2357 | [name: 'Lemon Chiffon', rgb: '#FFFACD'], 2358 | [name: 'Light Blue', rgb: '#ADD8E6'], 2359 | [name: 'Light Coral', rgb: '#F08080'], 2360 | [name: 'Light Cyan', rgb: '#E0FFFF'], 2361 | [name: 'Light Golden Rod Yellow', rgb: '#FAFAD2'], 2362 | [name: 'Light Gray', rgb: '#D3D3D3'], 2363 | [name: 'Light Green', rgb: '#90EE90'], 2364 | [name: 'Light Pink', rgb: '#FFB6C1'], 2365 | [name: 'Light Salmon', rgb: '#FFA07A'], 2366 | [name: 'Light Sea Green', rgb: '#20B2AA'], 2367 | [name: 'Light Sky Blue', rgb: '#87CEFA'], 2368 | [name: 'Light Slate Gray', rgb: '#778899'], 2369 | [name: 'Light Steel Blue', rgb: '#B0C4DE'], 2370 | [name: 'Light Yellow', rgb: '#FFFFE0'], 2371 | [name: 'Lime', rgb: '#00FF00'], 2372 | [name: 'Lime Green', rgb: '#32CD32'], 2373 | [name: 'Linen', rgb: '#FAF0E6'], 2374 | [name: 'Maroon', rgb: '#800000'], 2375 | [name: 'Medium Aquamarine', rgb: '#66CDAA'], 2376 | [name: 'Medium Blue', rgb: '#0000CD'], 2377 | [name: 'Medium Orchid', rgb: '#BA55D3'], 2378 | [name: 'Medium Purple', rgb: '#9370DB'], 2379 | [name: 'Medium Sea Green', rgb: '#3CB371'], 2380 | [name: 'Medium Slate Blue', rgb: '#7B68EE'], 2381 | [name: 'Medium Spring Green', rgb: '#00FA9A'], 2382 | [name: 'Medium Turquoise', rgb: '#48D1CC'], 2383 | [name: 'Medium Violet Red', rgb: '#C71585'], 2384 | [name: 'Midnight Blue', rgb: '#191970'], 2385 | [name: 'Mint Cream', rgb: '#F5FFFA'], 2386 | [name: 'Misty Rose', rgb: '#FFE4E1'], 2387 | [name: 'Moccasin', rgb: '#FFE4B5'], 2388 | [name: 'Navajo White', rgb: '#FFDEAD'], 2389 | [name: 'Navy', rgb: '#000080'], 2390 | [name: 'Old Lace', rgb: '#FDF5E6'], 2391 | [name: 'Olive', rgb: '#808000'], 2392 | [name: 'Olive Drab', rgb: '#6B8E23'], 2393 | [name: 'Orange', rgb: '#FFA500'], 2394 | [name: 'Orange Red', rgb: '#FF4500'], 2395 | [name: 'Orchid', rgb: '#DA70D6'], 2396 | [name: 'Pale Golden Rod', rgb: '#EEE8AA'], 2397 | [name: 'Pale Green', rgb: '#98FB98'], 2398 | [name: 'Pale Turquoise', rgb: '#AFEEEE'], 2399 | [name: 'Pale Violet Red', rgb: '#DB7093'], 2400 | [name: 'Papaya Whip', rgb: '#FFEFD5'], 2401 | [name: 'Peach Puff', rgb: '#FFDAB9'], 2402 | [name: 'Peru', rgb: '#CD853F'], 2403 | [name: 'Pink', rgb: '#FFC0CB'], 2404 | [name: 'Plum', rgb: '#DDA0DD'], 2405 | [name: 'Powder Blue', rgb: '#B0E0E6'], 2406 | [name: 'Purple', rgb: '#800080'], 2407 | [name: 'R.A.F. Blue', rgb: '#5D8AA8'], 2408 | [name: 'Red', rgb: '#FF0000'], 2409 | [name: 'Rosy Brown', rgb: '#BC8F8F'], 2410 | [name: 'Royal Blue', rgb: '#4169E1'], 2411 | [name: 'Saddle Brown', rgb: '#8B4513'], 2412 | [name: 'Salmon', rgb: '#FA8072'], 2413 | [name: 'Sandy Brown', rgb: '#F4A460'], 2414 | [name: 'Sea Green', rgb: '#2E8B57'], 2415 | [name: 'Sea Shell', rgb: '#FFF5EE'], 2416 | [name: 'Sienna', rgb: '#A0522D'], 2417 | [name: 'Silver', rgb: '#C0C0C0'], 2418 | [name: 'Sky Blue', rgb: '#87CEEB'], 2419 | [name: 'Slate Blue', rgb: '#6A5ACD'], 2420 | [name: 'Slate Gray', rgb: '#708090'], 2421 | [name: 'Snow', rgb: '#FFFAFA'], 2422 | [name: 'Soft White', rgb: '#B6DA7C'], 2423 | [name: 'Spring Green', rgb: '#00FF7F'], 2424 | [name: 'Steel Blue', rgb: '#4682B4'], 2425 | [name: 'Tan', rgb: '#D2B48C'], 2426 | [name: 'Teal', rgb: '#008080'], 2427 | [name: 'Thistle', rgb: '#D8BFD8'], 2428 | [name: 'Tomato', rgb: '#FF6347'], 2429 | [name: 'Turquoise', rgb: '#40E0D0'], 2430 | [name: 'U.S.A.F. Blue', rgb: '#00308F'], 2431 | [name: 'Violet', rgb: '#EE82EE'], 2432 | [name: 'Warm White', rgb: '#B0B893'], 2433 | [name: 'Wheat', rgb: '#F5DEB3'], 2434 | [name: 'White', rgb: '#FFFFFF'], 2435 | [name: 'White Smoke', rgb: '#F5F5F5'], 2436 | [name: 'Yellow', rgb: '#FFFF00'], 2437 | [name: 'Yellow Green', rgb: '#9ACD32'], 2438 | ] 2439 | 2440 | /** Create a map of types by the name */ 2441 | private Map flattenMessageTypes() { 2442 | def result = [:] 2443 | msgTypes.each { 2444 | k, v -> 2445 | v.each { 2446 | k2, v2 -> 2447 | result["$k.$k2"] = v2 2448 | } 2449 | } 2450 | 2451 | result 2452 | } 2453 | 2454 | private Map flattenedTypes() { 2455 | flattenMessageTypes().collectEntries { k, v -> [(k): v.type] } 2456 | } 2457 | 2458 | private Map flattenedDescriptors() { 2459 | flattenMessageTypes().collectEntries { k, v -> [(k): v.descriptor] } 2460 | } 2461 | 2462 | @Lazy @Field Map messageType = flattenedTypes() 2463 | @Lazy @Field Map descriptors = flattenedDescriptors() 2464 | 2465 | 2466 | @Field static final Map msgTypes = [ 2467 | DEVICE : [ 2468 | GET_SERVICE : [type: 2, descriptor: ''], 2469 | STATE_SERVICE : [type: 3, descriptor: 'service:b;port:i'], 2470 | GET_HOST_INFO : [type: 12, descriptor: ''], 2471 | STATE_HOST_INFO : [type: 13, descriptor: 'signal:f;tx:i;rx:i,reservedHost:w'], 2472 | GET_HOST_FIRMWARE : [type: 14, descriptor: ''], 2473 | STATE_HOST_FIRMWARE: [type: 15, descriptor: 'build:l;reservedFirmware:l;version_minor:w;version_major:w'], 2474 | GET_WIFI_INFO : [type: 16, descriptor: ''], 2475 | STATE_WIFI_INFO : [type: 17, descriptor: 'signal:f;tx:i;rx:i,reservedWifi:w'], 2476 | GET_WIFI_FIRMWARE : [type: 18, descriptor: ''], 2477 | STATE_WIFI_FIRMWARE: [type: 19, descriptor: 'build:l;reservedFirmware:l;version:i'], 2478 | GET_POWER : [type: 20, descriptor: ''], 2479 | SET_POWER : [type: 21, descriptor: 'powerLevel:w'], 2480 | STATE_POWER : [type: 22, descriptor: 'powerLevel:w'], 2481 | GET_LABEL : [type: 23, descriptor: ''], 2482 | SET_LABEL : [type: 24, descriptor: 'label:t32'], 2483 | STATE_LABEL : [type: 25, descriptor: 'label:t32'], 2484 | GET_VERSION : [type: 32, descriptor: ''], 2485 | STATE_VERSION : [type: 33, descriptor: 'vendor:i;product:i;version:i'], 2486 | GET_INFO : [type: 34, descriptor: ''], 2487 | STATE_INFO : [type: 35, descriptor: 'time:l;uptime:l;downtime:l'], 2488 | ACKNOWLEDGEMENT : [type: 45, descriptor: ''], 2489 | GET_LOCATION : [type: 48, descriptor: ''], 2490 | SET_LOCATION : [type: 49, descriptor: 'location:ba16;label:t32;updated_at:l'], 2491 | STATE_LOCATION : [type: 50, descriptor: 'location:ba16;label:t32;updated_at:l'], 2492 | GET_GROUP : [type: 51, descriptor: ''], 2493 | SET_GROUP : [type: 52, descriptor: 'group:ba16;label:t32;updated_at:l'], 2494 | STATE_GROUP : [type: 53, descriptor: 'group:ba16;label:t32;updated_at:l'], 2495 | ECHO_REQUEST : [type: 58, descriptor: 'payload:ba64'], 2496 | ECHO_RESPONSE : [type: 59, descriptor: 'payload:ba64'], 2497 | ], 2498 | LIGHT : [ 2499 | GET_STATE : [type: 101, descriptor: ''], 2500 | SET_COLOR : [type: 102, descriptor: "reservedColor:b;color:h;duration:i"], 2501 | SET_WAVEFORM : [type: 103, descriptor: "reservedWaveform:b;transient:b;color:h;period:i;cycles:f;skew_ratio:w;waveform:b"], 2502 | SET_WAVEFORM_OPTIONAL: [type: 119, descriptor: "reservedWaveform:b;transient:b;color:h;period:i;cycles:f;skew_ratio:w;waveform:b;setColor:h"], 2503 | STATE : [type: 107, descriptor: "color:h;reserved1State:w;power:w;label:t32;reserved2state:l"], 2504 | GET_POWER : [type: 116, descriptor: ''], 2505 | SET_POWER : [type: 117, descriptor: 'powerLevel:w;duration:i'], 2506 | STATE_POWER : [type: 118, descriptor: 'powerLevel:w'], 2507 | GET_INFRARED : [type: 120, descriptor: ''], 2508 | STATE_INFRARED : [type: 121, descriptor: 'irLevel:w'], 2509 | SET_INFRARED : [type: 122, descriptor: 'irLevel:w'], 2510 | ], 2511 | MULTIZONE: [ 2512 | SET_COLOR_ZONES : [type: 501, descriptor: "start_index:b;end_index:b;color:h;duration:i;apply:b"], 2513 | GET_COLOR_ZONES : [type: 502, descriptor: 'start_index:b;end_index:b'], 2514 | STATE_ZONE : [type: 503, descriptor: "zone_count:b;index:b;color:h"], 2515 | STATE_MULTIZONE : [type: 506, descriptor: "zone_count:b;index:b;colors:ha8"], 2516 | GET_MULTIZONE_EFFECT : [type: 507, descriptor: ''], 2517 | SET_MULTIZONE_EFFECT : [type: 508, descriptor: 'instanceId:i;type:b;reserved1Effect:w;speed:i;duration:l;reserved2Effect:i;reserved3Effect:i;parameters:ia8'], 2518 | STATE_MULTIZONE_EFFECT : [type: 509, descriptor: 'instanceId:i;type:b;reserved1Effect:w;speed:i;duration:l;reserved2Effect:i;reserved3Effect:i;parameters:ia8'], 2519 | SET_EXTENDED_COLOR_ZONES : [type: 510, descriptor: 'duration:i;apply:b;index:w;colors_count:b;colors:ha82'], 2520 | GET_EXTENDED_COLOR_ZONES : [type: 511, descriptor: ''], 2521 | STATE_EXTENDED_COLOR_ZONES: [type: 512, descriptor: 'zone_count:w;index:w;colors_count:b;colors:ha82'], 2522 | ], 2523 | TILE: [ 2524 | GET_TILE_EFFECT : [type: 718, descriptor: ''], 2525 | SET_TILE_EFFECT : [type: 719, descriptor: 'reserved1Effect:b;reserved2Effect:b;instanceId:i;type:b;speed:i;duration:l;reserved3Effect:i;reserved4Effect:i;parameters:ia8;palette_count:b;palette:ha8'], 2526 | STATE_TILE_EFFECT: [type: 720, descriptor: 'reserved1Effect:b;instanceId:i;type:b;speed:i;duration:l;reserved2Effect:i;reserved3Effect:i;parameters:ia8;palette_count:b;palette:ha8'], 2527 | ] 2528 | ] 2529 | 2530 | String renderMultizone(HashMap hashMap) { 2531 | def builder = new StringBuilder(); 2532 | builder << '' 2533 | def count = (hashMap.colors_count ?: hashMap.zone_count) as Integer 2534 | Map colours = hashMap.colors 2535 | builder << '' 2536 | for (int i = 0; i < count; i++) { 2537 | colour = colours[i]; 2538 | def rgb = renderDatum(colours[i]) 2539 | builder << '
 ' 2540 | } 2541 | builder << '
' 2542 | def result = builder.toString() 2543 | 2544 | result 2545 | } 2546 | 2547 | String renderDatum(Map item) { 2548 | def rgb = hsvToRgbString( 2549 | scaleDown100((item?.hue ?: 0) as Long), 2550 | scaleDown100((item?.saturation ?: 0) as Long), 2551 | scaleDown100((item?.brightness ?: 0) as Long) 2552 | ) 2553 | "$rgb" 2554 | } 2555 | 2556 | 2557 | Map stringToHsbk(String data) { 2558 | def m = data =~ /^(\p{XDigit}{4})(\p{XDigit}{4})(\p{XDigit}{4})(\p{XDigit}{4})(\p{XDigit}{0,2})$/ 2559 | if (m) { 2560 | def hue = Long.parseLong(m.group(1), 16) 2561 | def sat = Long.parseLong(m.group(2), 16) 2562 | def bri = Long.parseLong(m.group(3), 16) 2563 | def kel = Long.parseLong(m.group(4), 16) 2564 | def count = 1 2565 | if (m.group(5)) { 2566 | count = Integer.parseInt(m.group(5), 16) 2567 | } 2568 | [hue: hue, saturation: sat, brightness: bri, kelvin: kel, count: count] 2569 | } 2570 | } 2571 | 2572 | List unpack(String packed) { 2573 | def matcher = packed =~ /\p{XDigit}{16}/ 2574 | List result = matcher[0..-1].collect() { 2575 | it as String 2576 | } 2577 | result 2578 | } 2579 | 2580 | List unRle(String compressed) { 2581 | def matcher = compressed =~ /\p{XDigit}{18}/ 2582 | List temp = matcher[0..-1].collect() { 2583 | it as String 2584 | } 2585 | List result = [] 2586 | temp.each { 2587 | def value = it.substring(0, 16) 2588 | def count = Integer.parseInt(it.substring(16), 16) 2589 | for (int i = 0; i < count; i++) { 2590 | result << value 2591 | } 2592 | } 2593 | result 2594 | } 2595 | 2596 | List decompress(String compressed) { 2597 | if (compressed.startsWith('@')) { 2598 | unpack(compressed.substring(1)) 2599 | } else if (compressed.startsWith('*')) { 2600 | unRle(compressed.substring(1)) 2601 | } else { 2602 | [] 2603 | } 2604 | } 2605 | 2606 | Map getZones(String compressed) { 2607 | List colors = decompress(compressed).collect { stringToHsbk(it) } 2608 | def numZones = colors.size() 2609 | while (colors.size() < 82) { 2610 | colors << [hue: 0, saturation: 0, brightness: 0, kelvin: 0] 2611 | } 2612 | def realColors = [:] 2613 | colors.eachWithIndex { v, k -> realColors[k] = v } 2614 | [index: 0, zone_count: numZones, colors_count: numZones, colors: realColors] 2615 | } 2616 | 2617 | 2618 | 2619 | -------------------------------------------------------------------------------- /LIFXMultiZone.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Copyright 2018, 2019 Robert Heyes. All Rights Reserved 4 | * 5 | * This software is free for Private Use. You may use and modify the software without distributing it. 6 | * You may not grant a sublicense to modify and distribute this software to third parties. 7 | * Software is provided without warranty and your use of it is at your own risk. 8 | * 9 | */ 10 | metadata { 11 | definition(name: 'LIFX Multizone', namespace: 'robheyes', author: 'Robert Alan Heyes', importUrl: 'https://raw.githubusercontent.com/robheyes/lifxcode/master/LIFXMultiZone.groovy') { 12 | capability 'Light' 13 | capability 'ColorControl' 14 | capability 'ColorTemperature' 15 | capability 'Polling' 16 | capability 'Initialize' 17 | capability 'Switch' 18 | capability "SwitchLevel" 19 | 20 | attribute "label", "string" 21 | attribute "group", "string" 22 | attribute "location", "string" 23 | attribute "multizone", "string" 24 | attribute "effect", "string" 25 | 26 | command "setState", ["MAP"] 27 | command "zonesSave", [[name: "Zone name*", type: "STRING"]] 28 | command "zonesDelete", [[name: "Zone name*", type: "STRING"]] 29 | command "zonesLoad", [[name: "Zone name*", type: "STRING",], [name: "Duration", type: "NUMBER"]] 30 | command "setZones", [[name: "Zone HBSK Map*", type: "STRING"], [name: "Duration", type: "NUMBER"]] 31 | command "setEffect", [[name: "Effect type*", type: "ENUM", constraints: ["MOVE", "OFF"]], [name: "Direction", type: "ENUM", constraints: ["forward", "reverse"]], [name: "Speed", type: "NUMBER"]] 32 | command "createChildDevices", [[name: "Label prefix*", type: "STRING"]] 33 | command "deleteChildDevices" 34 | command 'setWaveform', [[name: 'Waveform*', type: 'ENUM', constraints:['SAW', 'SINE', 'HALF_SINE', 'TRIANGLE', 'PULSE']], [name: 'Color*', type: 'STRING'], [name: 'Transient', type: 'ENUM', constraints: ['true', 'false']], [name: 'Period', type: 'NUMBER'], [name: 'Cycles', type: 'NUMBER'], [name: 'Skew Ratio', type: 'NUMBER']] 35 | } 36 | 37 | 38 | preferences { 39 | input "useActivityLogFlag", "bool", title: "Enable activity logging", required: false 40 | input "useDebugActivityLogFlag", "bool", title: "Enable debug logging", required: false 41 | } 42 | } 43 | 44 | @SuppressWarnings("unused") 45 | def installed() { 46 | initialize() 47 | } 48 | 49 | @SuppressWarnings("unused") 50 | def updated() { 51 | initialize() 52 | } 53 | 54 | def initialize() { 55 | state.transitionTime = defaultTransition 56 | state.useActivityLog = useActivityLogFlag 57 | state.useActivityLogDebug = useDebugActivityLogFlag 58 | unschedule() 59 | getDeviceFirmware() 60 | requestInfo() 61 | runEvery1Minute poll 62 | } 63 | 64 | @SuppressWarnings("unused") 65 | def refresh() { 66 | 67 | } 68 | 69 | def createChildDevices(String prefix) { 70 | def zoneCount = state.zoneCount 71 | for (i=0; i [k as Integer, v] } 102 | } 103 | theZones ?: [colors: [:]] 104 | } 105 | 106 | @SuppressWarnings("unused") 107 | def zonesSave(String name) { 108 | if (name == '') { 109 | return 110 | } 111 | def zones = state.namedZones ?: [:] 112 | def theZones = loadLastMultizone() 113 | def compressed = compressMultizoneData theZones 114 | zones[name] = compressed 115 | state.namedZones = zones 116 | state.knownZones = zones.keySet().toString() 117 | } 118 | 119 | def setZones(String colors, duration = 0) { 120 | def theZones = loadLastMultizone() 121 | def count = theZones.zone_count 122 | def newZones = [colors: [:], index: 0, apply: 1, duration: duration * 1000, colors_count: count, zone_count: count] 123 | def colorsMap = stringToMap(colors) 124 | colorsMap = colorsMap.collectEntries {k, v -> [k as Integer, stringToMap(v)] } 125 | for (i=0; i sendPacket buffer } 200 | parent.lifxQuery(device, 'LIGHT.GET_STATE') { List buffer -> sendPacket buffer } 201 | if (extMzSupported()) { 202 | parent.lifxQuery(device, 'MULTIZONE.GET_EXTENDED_COLOR_ZONES') { List buffer -> sendPacket buffer } 203 | } else { 204 | parent.lifxQuery(device, 'MULTIZONE.GET_COLOR_ZONES', [start_index: 0, end_index: 7]) {List buffer -> sendPacket buffer } 205 | } 206 | parent.lifxQuery(device, 'MULTIZONE.GET_MULTIZONE_EFFECT') { List buffer -> sendPacket buffer } 207 | } 208 | 209 | def requestInfo() { 210 | poll() 211 | } 212 | 213 | def getDeviceFirmware() { 214 | parent.lifxQuery(device, 'DEVICE.GET_HOST_FIRMWARE') { List buffer -> sendPacket buffer } 215 | } 216 | 217 | def extMzSupported() { 218 | Float curr = new Float(state.firmware ?: 2.77) 219 | Float minExtMz = 2.77 //2.77 changed to test legacy protocol 220 | return (curr >= minExtMz) 221 | } 222 | 223 | def updateChildDevices(multizoneData) { 224 | def power = device.currentValue("switch") 225 | def colors = (multizoneData as Map).colors 226 | colors = colors.collectEntries { k, v -> [k as Integer, v] } 227 | def children = getChildDevices() 228 | for (child in children) { 229 | def zone = child.getDataValue("zone") as Integer 230 | child.sendEvent(name: "hue", value: parent.scaleDown100(colors[zone].hue)) 231 | child.sendEvent(name: "level", value: parent.scaleDown100(colors[zone].brightness)) 232 | child.sendEvent(name: "saturation", value: parent.scaleDown100(colors[zone].saturation)) 233 | child.sendEvent(name: "colorTemperature", value: colors[zone].kelvin) 234 | colors[zone].brightness ? child.sendEvent(name: "switch", value: power) : child.sendEvent(name: "switch", value: "off") 235 | } 236 | } 237 | 238 | def on() { 239 | sendActions parent.deviceOnOff('on', getUseActivityLog(), state.transitionTime ?: 0) 240 | } 241 | 242 | def off() { 243 | sendActions parent.deviceOnOff('off', getUseActivityLog(), state.transitionTime ?: 0) 244 | } 245 | 246 | @SuppressWarnings("unused") 247 | def setColor(Map colorMap) { 248 | sendActions parent.deviceSetColor(device, colorMap, getUseActivityLogDebug(), state.transitionTime ?: 0) 249 | } 250 | 251 | @SuppressWarnings("unused") 252 | def setHue(number) { 253 | setZones('999:"[hue: ' + number + ']"', state.transitionTime ?: 0) 254 | } 255 | 256 | @SuppressWarnings("unused") 257 | def setSaturation(number) { 258 | setZones('999:"[saturation: ' + number + ']"', state.transitionTime ?: 0) 259 | } 260 | 261 | @SuppressWarnings("unused") 262 | def setColorTemperature(temperature) { 263 | setZones('999:"[saturation: 0, kelvin: ' + temperature + ']"', state.transitionTime ?: 0) 264 | } 265 | 266 | @SuppressWarnings("unused") 267 | def setLevel(level, duration = 0) { 268 | if ((null == level || level <= 0) && 0 == duration) { 269 | off() 270 | } else { 271 | setZones('999:"[brightness: ' + level + ']"', duration) 272 | } 273 | } 274 | 275 | @SuppressWarnings("unused") 276 | def setState(value) { 277 | sendActions parent.deviceSetState(device, stringToMap(value), getUseActivityLog(), state.transitionTime ?: 0) 278 | } 279 | 280 | def setWaveform(String waveform, String color, String isTransient = 'true', period = 5, cycles = 3.40282346638528860e38, skew = 0.5) { 281 | sendActions parent.deviceSetWaveform(device, isTransient.toBoolean(), stringToMap(color), period.toInteger(), cycles.toFloat(), skew.toFloat(), waveform) 282 | } 283 | 284 | private void sendActions(Map actions) { 285 | actions.commands?.each { item -> parent.lifxCommand(device, item.cmd, item.payload) { List buffer -> sendPacket buffer, true } } 286 | actions.events?.each { sendEvent it } 287 | } 288 | 289 | def parse(String description) { 290 | List events = parent.parseForDevice(device, description, getUseActivityLog()) 291 | def firmwareEvent = events.find { it.name == 'firmware' } 292 | firmwareEvent?.data ? state.firmware = firmwareEvent.data : null 293 | def multizoneEvent = events.find { it.name == 'multizone' } 294 | if (multizoneEvent?.data) { 295 | updateChildDevices(multizoneEvent.data) 296 | state.lastMultizone = multizoneEvent.data 297 | state.zoneCount = multizoneEvent.data.zone_count 298 | if (!extMzSupported() && (multizoneEvent.data.zone_count - multizoneEvent.data.currentIndex) > 8) { 299 | //query next set of 8 zones 300 | def nextIndex = multizoneEvent.data.currentIndex + 8 301 | parent.lifxQuery(device, 'MULTIZONE.GET_COLOR_ZONES', [start_index: (nextIndex), end_index: (nextIndex + 7)]) {List buffer -> sendPacket buffer } 302 | } 303 | } 304 | events.collect { createEvent(it) } 305 | } 306 | 307 | private String myIp() { 308 | device.getDeviceNetworkId() 309 | } 310 | 311 | private void sendPacket(List buffer, boolean noResponseExpected = false) { 312 | logDebug "Sending buffer $buffer" 313 | String stringBytes = hubitat.helper.HexUtils.byteArrayToHexString parent.asByteArray(buffer) 314 | sendHubCommand( 315 | new hubitat.device.HubAction( 316 | stringBytes, 317 | hubitat.device.Protocol.LAN, 318 | [ 319 | type : hubitat.device.HubAction.Type.LAN_TYPE_UDPCLIENT, 320 | destinationAddress: myIp() + ":56700", 321 | encoding : hubitat.device.HubAction.Encoding.HEX_STRING, 322 | ignoreResponse : noResponseExpected 323 | ] 324 | ) 325 | ) 326 | } 327 | 328 | // 329 | //String renderMultizone(HashMap hashMap) { 330 | // def builder = new StringBuilder(); 331 | // builder << '' 332 | // def count = hashMap.colors_count as Integer 333 | // Map colours = hashMap.colors 334 | // builder << '' 335 | // for (int i = 0; i < count; i++) { 336 | // colour = colours[i]; 337 | // def rgb = renderDatum(colours[i]) 338 | // builder << '
 ' 339 | // } 340 | // builder << '
' 341 | // def result = builder.toString() 342 | // 343 | // result 344 | //} 345 | 346 | //String renderDatum(Map item) { 347 | // def rgb = parent.hsvToRgbString( 348 | // parent.scaleDown100(item.hue as Long), 349 | // parent.scaleDown100(item.saturation as Long), 350 | // parent.scaleDown100(item.brightness as Long) 351 | // ) 352 | // "$rgb" 353 | //} 354 | String rle(List colors) { 355 | StringBuilder builder = new StringBuilder('*') 356 | StringBuilder uniqueBuilder = new StringBuilder('@') 357 | String current = null 358 | Integer count = 0 359 | boolean allUnique = true 360 | colors.each { 361 | uniqueBuilder << it 362 | uniqueBuilder << "\n" 363 | if (it != current) { 364 | if (count > 0) { 365 | builder << sprintf("%02x\n", count) 366 | } 367 | count = 1 368 | current = it 369 | builder << current 370 | } else { 371 | count++ 372 | allUnique = false 373 | } 374 | } 375 | if (count != 0) { 376 | builder << sprintf('%02x', count) 377 | } 378 | if (allUnique) { 379 | uniqueBuilder.toString() 380 | } else { 381 | builder.toString() 382 | } 383 | } 384 | 385 | String hsbkToString(Map hsbk) { 386 | sprintf '%04x%04x%04x%04x', hsbk.hue, hsbk.saturation, hsbk.brightness, hsbk.kelvin 387 | } 388 | 389 | String compressMultizoneData(Map data) { 390 | Integer count = data.colors_count as Integer 391 | logDebug "Count: $count" 392 | Map colors = data.colors as Map 393 | logDebug "colors: $colors" 394 | List stringColors = [] 395 | for (int i = 0; i < count; i++) { 396 | Map hsbk = colors[i] 397 | logDebug "Colors[$i]: $hsbk" 398 | stringColors << hsbkToString(hsbk) 399 | } 400 | rle stringColors 401 | } 402 | 403 | def getUseActivityLog() { 404 | if (state.useActivityLog == null) { 405 | state.useActivityLog = true 406 | } 407 | return state.useActivityLog 408 | } 409 | 410 | def setUseActivityLog(value) { 411 | log.debug("Setting useActivityLog to ${value ? 'true':'false'}") 412 | state.useActivityLog = value 413 | } 414 | 415 | def getUseActivityLogDebug() { 416 | if (state.useActivityLogDebug == null) { 417 | state.useActivityLogDebug = false 418 | } 419 | return state.useActivityLogDebug 420 | } 421 | 422 | def setUseActivityLogDebug(value) { 423 | log.debug("Setting useActivityLogDebug to ${value ? 'true':'false'}") 424 | state.useActivityLogDebug = value 425 | } 426 | 427 | void logDebug(msg) { 428 | if (getUseActivityLogDebug()) { 429 | log.debug msg 430 | } 431 | } 432 | 433 | void logInfo(msg) { 434 | if (getUseActivityLog()) { 435 | log.info msg 436 | } 437 | } 438 | 439 | void logWarn(String msg) { 440 | if (getUseActivityLog()) { 441 | log.warn msg 442 | } 443 | } 444 | 445 | -------------------------------------------------------------------------------- /LIFXMultiZoneChild.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Copyright 2020 David Kilgore. All Rights Reserved 4 | * 5 | * This software is free for Private Use. You may use and modify the software without distributing it. 6 | * If you make a fork, and add new code, then you should create a pull request to add value, there is no 7 | * guarantee that your pull request will be merged. 8 | * 9 | * You may not grant a sublicense to modify and distribute this software to third parties without permission 10 | * from the copyright holder 11 | * Software is provided without warranty and your use of it is at your own risk. 12 | * 13 | */ 14 | 15 | metadata { 16 | definition(name: "LIFX Multizone Child", namespace: "robheyes", author: "David Kilgore", importUrl: 'https://raw.githubusercontent.com/robheyes/lifxcode/master/LIFXMultiZoneChild.groovy') { 17 | capability 'Light' 18 | capability 'ColorControl' 19 | capability 'ColorTemperature' 20 | capability 'Initialize' 21 | capability 'Switch' 22 | capability "SwitchLevel" 23 | 24 | attribute "label", "string" 25 | attribute "zone", "number" 26 | command "setState", ["MAP"] 27 | } 28 | 29 | preferences { 30 | input "useActivityLogFlag", "bool", title: "Enable activity logging", required: false 31 | input "useDebugActivityLogFlag", "bool", title: "Enable debug logging", required: false 32 | } 33 | } 34 | 35 | @SuppressWarnings("unused") 36 | def installed() { 37 | initialize() 38 | } 39 | 40 | @SuppressWarnings("unused") 41 | def updated() { 42 | initialize() 43 | } 44 | 45 | def initialize() { 46 | state.useActivityLog = useActivityLogFlag 47 | state.useActivityLogDebug = useDebugActivityLogFlag 48 | unschedule() 49 | } 50 | 51 | @SuppressWarnings("unused") 52 | def refresh() { 53 | 54 | } 55 | 56 | def getZone() { 57 | return device.getDataValue("zone") 58 | } 59 | 60 | def on() { 61 | parent.setZones(getZone() + ': "[brightness:100]"') 62 | device.sendEvent(name: "switch", value: "on") 63 | device.sendEvent(name: "level", value: 100) 64 | } 65 | 66 | def off() { 67 | parent.setZones(getZone() + ': "[brightness:0]"') 68 | device.sendEvent(name: "switch", value: "off") 69 | device.sendEvent(name: "level", value: 0) 70 | } 71 | 72 | @SuppressWarnings("unused") 73 | def setColor(Map colorMap) { 74 | parent.setZones(getZone() + ': "[hue: ' + colorMap.hue + ', saturation: ' + colorMap.saturation + ', brightness: '+ colorMap.level + ']"') 75 | device.sendEvent(name: "hue", value: colorMap.hue) 76 | device.sendEvent(name: "saturation", value: colorMap.saturation) 77 | device.sendEvent(name: "level", value: colorMap.level) 78 | if (colorMap.level > 0) { 79 | device.sendEvent(name: "switch", value: "on") 80 | } else if (colorMap.level == 0) { 81 | device.sendEvent(name: "switch", value: "off") 82 | } 83 | } 84 | 85 | @SuppressWarnings("unused") 86 | def setHue(hue) { 87 | parent.setZones(getZone() + ': "[hue: ' + hue + ']"') 88 | device.sendEvent(name: "hue", value: hue) 89 | } 90 | 91 | @SuppressWarnings("unused") 92 | def setSaturation(saturation) { 93 | parent.setZones(getZone() + ': "[saturation: ' + saturation + ']"') 94 | device.sendEvent(name: "saturation", value: saturation) 95 | } 96 | 97 | @SuppressWarnings("unused") 98 | def setColorTemperature(temperature) { 99 | parent.setZones(getZone() + ': "[saturation: 0, kelvin: ' + temperature + ']"') 100 | device.sendEvent(name: "colorTemperature", value: temperature) 101 | device.sendEvent(name: "saturation", value: 0) 102 | } 103 | 104 | @SuppressWarnings("unused") 105 | def setLevel(level, duration = 0) { 106 | parent.setZones(getZone() + ': "[brightness: ' + level + ']"', duration) 107 | device.sendEvent(name: "level", value: level) 108 | } 109 | 110 | def setState(value, duration = 0) { 111 | parent.setZones(getZone() + ': "' + value + '"', duration) 112 | } 113 | 114 | def getUseActivityLog() { 115 | if (state.useActivityLog == null) { 116 | state.useActivityLog = true 117 | } 118 | return state.useActivityLog 119 | } 120 | 121 | def setUseActivityLog(value) { 122 | log.debug("Setting useActivityLog to ${value ? 'true' : 'false'}") 123 | state.useActivityLog = value 124 | } 125 | 126 | Boolean getUseActivityLogDebug() { 127 | if (state.useActivityLogDebug == null) { 128 | state.useActivityLogDebug = false 129 | } 130 | return state.useActivityLogDebug as Boolean 131 | } 132 | 133 | def setUseActivityLogDebug(value) { 134 | log.debug("Setting useActivityLogDebug to ${value ? 'true' : 'false'}") 135 | state.useActivityLogDebug = value 136 | } 137 | 138 | void logDebug(msg) { 139 | if (getUseActivityLogDebug()) { 140 | log.debug msg 141 | } 142 | } 143 | 144 | void logInfo(msg) { 145 | if (getUseActivityLog()) { 146 | log.info msg 147 | } 148 | } 149 | 150 | void logWarn(String msg) { 151 | if (getUseActivityLog()) { 152 | log.warn msg 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /LIFXPlusColor.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Copyright 2019 Robert Heyes. All Rights Reserved 4 | * 5 | * This software is free for Private Use. You may use and modify the software without distributing it. 6 | * If you make a fork, and add new code, then you should create a pull request to add value, there is no 7 | * guarantee that your pull request will be merged. 8 | * 9 | * You may not grant a sublicense to modify and distribute this software to third parties without permission 10 | * from the copyright holder 11 | * Software is provided without warranty and your use of it is at your own risk. 12 | * 13 | */ 14 | 15 | metadata { 16 | definition(name: "LIFXPlus Color", namespace: "robheyes", author: "Robert Alan Heyes", importUrl: 'https://raw.githubusercontent.com/robheyes/lifxcode/master/LIFXPlusColor.groovy') { 17 | capability "Bulb" 18 | capability "ColorTemperature" 19 | capability "Polling" 20 | capability "Switch" 21 | capability "SwitchLevel" 22 | capability "Initialize" 23 | capability "ColorControl" 24 | capability "ColorMode" 25 | capability 'ChangeLevel' 26 | 27 | attribute "label", "string" 28 | attribute "group", "string" 29 | attribute "location", "string" 30 | attribute "IrLevel", 'number' 31 | 32 | // need a command to set the ir level 33 | command 'setInfraredLevel', [[name: 'Level', type: 'NUMBER'], [name: 'duration', type: 'NUMBER']] 34 | command 'setWaveform', [[name: 'Waveform*', type: 'ENUM', constraints:['SAW', 'SINE', 'HALF_SINE', 'TRIANGLE', 'PULSE']], [name: 'Color*', type: 'STRING'], [name: 'Transient', type: 'ENUM', constraints: ['true', 'false']], [name: 'Period', type: 'NUMBER'], [name: 'Cycles', type: 'NUMBER'], [name: 'Skew Ratio', type: 'NUMBER']] 35 | } 36 | 37 | preferences { 38 | input "useActivityLogFlag", "bool", title: "Enable activity logging", required: false 39 | input "useDebugActivityLogFlag", "bool", title: "Enable debug logging", required: false 40 | input "defaultTransition", "decimal", title: "Color map level transition time", description: "Set color time (seconds)", required: true, defaultValue: 0.0 41 | input "changeLevelStep", 'decimal', title: "Change level step size", description: "", required: false, defaultValue: 1 42 | input "changeLevelEvery", 'number', title: "Change Level every x milliseconds", description: "", required: false, defaultValue: 20 43 | } 44 | } 45 | 46 | @SuppressWarnings("unused") 47 | def installed() { 48 | initialize() 49 | } 50 | 51 | @SuppressWarnings("unused") 52 | def updated() { 53 | 54 | initialize() 55 | } 56 | 57 | def initialize() { 58 | state.transitionTime = defaultTransition 59 | state.useActivityLog = useActivityLogFlag 60 | state.useActivityLogDebug = useDebugActivityLogFlag 61 | state.changeLevelEvery = changeLevelEvery 62 | state.changeLevelStep = changeLevelStep 63 | unschedule() 64 | requestInfo() 65 | runEvery1Minute poll 66 | } 67 | 68 | @SuppressWarnings("unused") 69 | def refresh() { 70 | 71 | } 72 | 73 | @SuppressWarnings("unused") 74 | def poll() { 75 | parent.lifxQuery(device, 'DEVICE.GET_POWER') { List buffer -> sendPacket buffer } 76 | parent.lifxQuery(device, 'LIGHT.GET_STATE') { List buffer -> sendPacket buffer } 77 | parent.lifxQuery(device, 'LIGHT.GET_INFRARED') { List buffer -> sendPacket buffer } 78 | } 79 | 80 | def requestInfo() { 81 | parent.lifxQuery(device, 'LIGHT.GET_STATE') { List buffer -> sendPacket buffer } 82 | } 83 | 84 | def on() { 85 | sendActions parent.deviceOnOff('on', getUseActivityLog(), state.transitionTime ?: 0) 86 | } 87 | 88 | def off() { 89 | sendActions parent.deviceOnOff('off', getUseActivityLog(), state.transitionTime ?: 0) 90 | } 91 | 92 | @SuppressWarnings("unused") 93 | def setColor(Map colorMap) { 94 | sendActions parent.deviceSetColor(device, colorMap, getUseActivityLogDebug(), state.transitionTime ?: 0) 95 | } 96 | 97 | @SuppressWarnings("unused") 98 | def setHue(hue) { 99 | sendActions parent.deviceSetHue(device, hue, getUseActivityLog(), state.transitionTime ?: 0) 100 | } 101 | 102 | @SuppressWarnings("unused") 103 | def setSaturation(saturation) { 104 | sendActions parent.deviceSetSaturation(device, saturation, getUseActivityLog(), state.transitionTime ?: 0) 105 | } 106 | 107 | @SuppressWarnings("unused") 108 | def setColorTemperature(temperature, level = null, transitionTime = null) { 109 | sendActions parent.deviceSetColorTemperature(device, temperature, level, getUseActivityLog(), transitionTime ?: (state.transitionTime ?: 0)) 110 | } 111 | 112 | @SuppressWarnings("unused") 113 | def setLevel(level, duration = 0) { 114 | sendActions parent.deviceSetLevel(device, level as Number, getUseActivityLog(), duration) 115 | } 116 | 117 | @SuppressWarnings("unused") 118 | def setState(value) { 119 | sendActions parent.deviceSetState(device, stringToMap(value), getUseActivityLog(), state.transitionTime ?: 0) 120 | } 121 | 122 | @SuppressWarnings("unused") 123 | def setInfraredLevel(level, duration = 0) { 124 | sendActions parent.deviceSetIRLevel(device, level, getUseActivityLog(), duration) 125 | } 126 | 127 | @SuppressWarnings("unused") 128 | def startLevelChange(direction) { 129 | // logDebug "startLevelChange called with $direction" 130 | enableLevelChange() 131 | if (changeLevelStep && changeLevelEvery) { 132 | doLevelChange(direction == 'up' ? 1 : -1) 133 | } else { 134 | logDebug "No parameters" 135 | } 136 | } 137 | 138 | @SuppressWarnings("unused") 139 | def stopLevelChange() { 140 | sendEvent([name: "cancelLevelChange", value: 'yes', displayed: false]) 141 | } 142 | 143 | def enableLevelChange() { 144 | sendEvent([name: "cancelLevelChange", value: 'no', displayed: false]) 145 | } 146 | 147 | def doLevelChange(direction) { 148 | def cancelling = device.currentValue('cancelLevelChange') ?: 'no' 149 | if (cancelling == 'yes') { 150 | runInMillis 2 * (changeLevelEvery as Integer), "enableLevelChange" 151 | return; 152 | } 153 | def newLevel = device.currentValue('level') + ((direction as Float) * (changeLevelStep as Float)) 154 | def lastStep = false 155 | if (newLevel < 0) { 156 | newLevel = 0 157 | lastStep = true 158 | } else if (newLevel > 100) { 159 | newLevel = 100 160 | lastStep = true 161 | } 162 | sendActions parent.deviceSetLevel(device, newLevel, getUseActivityLog(), (changeLevelEvery - 1) / 1000) 163 | if (!lastStep) { 164 | runInMillis changeLevelEvery as Integer, "doLevelChange", [data: direction] 165 | } 166 | } 167 | 168 | def setWaveform(String waveform, String color, String isTransient = 'true', period = 5, cycles = 3.40282346638528860e38, skew = 0.5) { 169 | sendActions parent.deviceSetWaveform(device, isTransient.toBoolean(), stringToMap(color), period.toInteger(), cycles.toFloat(), skew.toFloat(), waveform) 170 | } 171 | 172 | private void sendActions(Map actions) { 173 | actions.commands?.each { item -> parent.lifxCommand(device, item.cmd, item.payload) { List buffer -> sendPacket buffer, true } } 174 | actions.events?.each { sendEvent it } 175 | } 176 | 177 | def parse(String description) { 178 | List events = parent.parseForDevice(device, description, getUseActivityLog()) 179 | events.collect { createEvent(it) } 180 | } 181 | 182 | private def myIp() { 183 | device.getDeviceNetworkId() 184 | } 185 | 186 | private void sendPacket(List buffer, boolean noResponseExpected = false) { 187 | String stringBytes = hubitat.helper.HexUtils.byteArrayToHexString parent.asByteArray(buffer) 188 | sendHubCommand( 189 | new hubitat.device.HubAction( 190 | stringBytes, 191 | hubitat.device.Protocol.LAN, 192 | [ 193 | type : hubitat.device.HubAction.Type.LAN_TYPE_UDPCLIENT, 194 | destinationAddress: myIp() + ":56700", 195 | encoding : hubitat.device.HubAction.Encoding.HEX_STRING, 196 | ignoreResponse : noResponseExpected 197 | ] 198 | ) 199 | ) 200 | } 201 | 202 | def getUseActivityLog() { 203 | if (state.useActivityLog == null) { 204 | state.useActivityLog = true 205 | } 206 | return state.useActivityLog 207 | } 208 | 209 | def setUseActivityLog(value) { 210 | log.debug("Setting useActivityLog to ${value ? 'true' : 'false'}") 211 | state.useActivityLog = value 212 | } 213 | 214 | def getUseActivityLogDebug() { 215 | if (state.useActivityLogDebug == null) { 216 | state.useActivityLogDebug = false 217 | } 218 | return state.useActivityLogDebug 219 | } 220 | 221 | def setUseActivityLogDebug(value) { 222 | log.debug("Setting useActivityLogDebug to ${value ? 'true' : 'false'}") 223 | state.useActivityLogDebug = value 224 | } 225 | 226 | void logDebug(msg) { 227 | if (getUseActivityLogDebug()) { 228 | log.debug msg 229 | } 230 | } 231 | 232 | void logInfo(msg) { 233 | if (getUseActivityLog()) { 234 | log.info msg 235 | } 236 | } 237 | 238 | void logWarn(String msg) { 239 | if (getUseActivityLog()) { 240 | log.warn msg 241 | } 242 | } -------------------------------------------------------------------------------- /LIFXTile.groovy: -------------------------------------------------------------------------------- 1 | import groovy.json.JsonSlurper 2 | 3 | /** 4 | * 5 | * Copyright 2018, 2019 Robert Heyes. All Rights Reserved 6 | * 7 | * This software is free for Private Use. You may use and modify the software without distributing it. 8 | * You may not grant a sublicense to modify and distribute this software to third parties. 9 | * Software is provided without warranty and your use of it is at your own risk. 10 | * 11 | */ 12 | 13 | metadata { 14 | definition(name: 'LIFX Tile', namespace: 'robheyes', author: 'Robert Alan Heyes', importUrl: 'https://raw.githubusercontent.com/robheyes/lifxcode/master/LIFXTile.groovy') { 15 | capability 'Light' 16 | capability 'ColorControl' 17 | capability 'ColorTemperature' 18 | capability 'Polling' 19 | capability 'Initialize' 20 | capability 'Switch' 21 | 22 | attribute "label", "string" 23 | attribute "group", "string" 24 | attribute "location", "string" 25 | attribute "effect", "string" 26 | 27 | command "setEffect", [[name: "Effect type*", type: "ENUM", constraints: ["FLAME", "MORPH", "OFF"]], [name: "Colors", type: "JSON_OBJECT"], [name: "Palette Count", type: "NUMBER"], [name: "Speed", type: "NUMBER"]] 28 | } 29 | 30 | preferences { 31 | input "useActivityLogFlag", "bool", title: "Enable activity logging", required: false 32 | input "useDebugActivityLogFlag", "bool", title: "Enable debug logging", required: false 33 | input "defaultTransition", "decimal", title: "Level transition time", description: "Set transition time (seconds)", required: true, defaultValue: 0.0 34 | } 35 | } 36 | 37 | @SuppressWarnings("unused") 38 | def installed() { 39 | initialize() 40 | } 41 | 42 | @SuppressWarnings("unused") 43 | def updated() { 44 | initialize() 45 | } 46 | 47 | def initialize() { 48 | state.transitionTime = defaultTransition 49 | state.useActivityLog = useActivityLogFlag 50 | state.useActivityLogDebug = useDebugActivityLogFlag 51 | unschedule() 52 | requestInfo() 53 | runEvery1Minute poll 54 | } 55 | 56 | @SuppressWarnings("unused") 57 | def refresh() { 58 | 59 | } 60 | 61 | @SuppressWarnings("unused") 62 | def poll() { 63 | parent.lifxQuery(device, 'DEVICE.GET_POWER') { List buffer -> sendPacket buffer } 64 | parent.lifxQuery(device, 'LIGHT.GET_STATE') { List buffer -> sendPacket buffer } 65 | parent.lifxQuery(device, 'TILE.GET_TILE_EFFECT') { List buffer -> sendPacket buffer } 66 | } 67 | 68 | def requestInfo() { 69 | parent.lifxQuery(device, 'LIGHT.GET_STATE') { List buffer -> sendPacket buffer } 70 | } 71 | 72 | def on() { 73 | sendActions parent.deviceOnOff('on', getUseActivityLog(), state.transitionTime ?: 0) 74 | } 75 | 76 | 77 | def off() { 78 | sendActions parent.deviceOnOff('off', getUseActivityLog(), state.transitionTime ?: 0) 79 | } 80 | 81 | def setEffect(String effectType, colors = '[]', palette_count = 16, speed = 30) { 82 | logDebug("Effect inputs -- type: $effectType, speed: $speed, palette_count: $palette_count, colors: $colors") 83 | def colorsList = new JsonSlurper().parseText(colors) 84 | if (colorsList.size() >= 1) { 85 | palette_count = colorsList.size() 86 | } 87 | def hsbkList = new Map[palette_count] 88 | for (int i = 0; i < palette_count; i++) { 89 | if (colorsList[i]) { 90 | String namedColor = colorsList[i].color ?: colorsList[i].colour 91 | if (namedColor) { 92 | Map myColor 93 | myColor = (null == namedColor) ? null : parent.lookupColor(namedColor.replace('_', ' ')) 94 | hsbkList[i] = [ 95 | hue : parent.scaleUp(myColor.h ?: 0, 360), 96 | saturation: parent.scaleUp100(myColor.s ?: 0), 97 | brightness: parent.scaleUp100(myColor.v ?: 50) 98 | ] 99 | } else { 100 | hsbkList[i] = parent.getScaledColorMap(colorsMap[i]) 101 | } 102 | } else { 103 | hsbkList[i] = [hue: 0, saturation: 0, brightnes: 0] 104 | } 105 | } 106 | logDebug("Sending effect command -- type: $effectType, speed: $speed, palette_count: $palette_count, hsbkList: $hsbkList") 107 | sendActions parent.deviceSetTileEffect(effectType, speed.toInteger(), palette_count.toInteger(), hsbkList) 108 | } 109 | 110 | @SuppressWarnings("unused") 111 | def setColor(Map colorMap) { 112 | logDebug("setColor: $colorMap") 113 | sendActions parent.deviceSetColor(device, colorMap, getUseActivityLogDebug(), state.transitionTime ?: 0) 114 | } 115 | 116 | @SuppressWarnings("unused") 117 | def setHue(hue) { 118 | logDebug("setHue: $hue") 119 | sendActions parent.deviceSetHue(device, hue, getUseActivityLog(), state.transitionTime ?: 0) 120 | } 121 | 122 | @SuppressWarnings("unused") 123 | def setSaturation(saturation) { 124 | logDebug("setSat: $saturation") 125 | sendActions parent.deviceSetSaturation(device, saturation, getUseActivityLog(), state.transitionTime ?: 0) 126 | } 127 | 128 | @SuppressWarnings("unused") 129 | def setColorTemperature(temperature, level = null, transitionTime = null) { 130 | sendActions parent.deviceSetColorTemperature(device, temperature, level, getUseActivityLog(), transitionTime ?: (state.transitionTime ?: 0)) 131 | } 132 | 133 | private void sendActions(Map actions) { 134 | actions.commands?.each { item -> parent.lifxCommand(device, item.cmd, item.payload) { List buffer -> sendPacket buffer, true } } 135 | actions.events?.each { sendEvent it } 136 | } 137 | 138 | def parse(String description) { 139 | List events = parent.parseForDevice(device, description, getUseActivityLog()) 140 | events.collect { createEvent(it) } 141 | } 142 | 143 | private String myIp() { 144 | device.getDeviceNetworkId() 145 | } 146 | 147 | private void sendPacket(List buffer, boolean noResponseExpected = false) { 148 | logDebug(buffer) 149 | String stringBytes = hubitat.helper.HexUtils.byteArrayToHexString parent.asByteArray(buffer) 150 | sendHubCommand( 151 | new hubitat.device.HubAction( 152 | stringBytes, 153 | hubitat.device.Protocol.LAN, 154 | [ 155 | type : hubitat.device.HubAction.Type.LAN_TYPE_UDPCLIENT, 156 | destinationAddress: myIp() + ":56700", 157 | encoding : hubitat.device.HubAction.Encoding.HEX_STRING, 158 | ignoreResponse : noResponseExpected 159 | ] 160 | ) 161 | ) 162 | } 163 | 164 | def getUseActivityLog() { 165 | if (state.useActivityLog == null) { 166 | state.useActivityLog = true 167 | } 168 | return state.useActivityLog 169 | } 170 | 171 | def setUseActivityLog(value) { 172 | log.debug("Setting useActivityLog to ${value ? 'true':'false'}") 173 | state.useActivityLog = value 174 | } 175 | 176 | def getUseActivityLogDebug() { 177 | if (state.useActivityLogDebug == null) { 178 | state.useActivityLogDebug = false 179 | } 180 | return state.useActivityLogDebug 181 | } 182 | 183 | def setUseActivityLogDebug(value) { 184 | log.debug("Setting useActivityLogDebug to ${value ? 'true':'false'}") 185 | state.useActivityLogDebug = value 186 | } 187 | 188 | void logDebug(msg) { 189 | if (getUseActivityLogDebug()) { 190 | log.debug msg 191 | } 192 | } 193 | 194 | void logInfo(msg) { 195 | if (getUseActivityLog()) { 196 | log.info msg 197 | } 198 | } 199 | 200 | void logWarn(String msg) { 201 | if (getUseActivityLog()) { 202 | log.warn msg 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /LIFXUnknown.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Copyright 2019 Robert Heyes. All Rights Reserved 4 | * 5 | * This software is free for Private Use. You may use and modify the software without distributing it. 6 | * If you make a fork, and add new code, then you should create a pull request to add value, there is no 7 | * guarantee that your pull request will be merged. 8 | * 9 | * You may not grant a sublicense to modify and distribute this software to third parties without permission 10 | * from the copyright holder 11 | * Software is provided without warranty and your use of it is at your own risk. 12 | * 13 | */ 14 | 15 | metadata { 16 | definition(name: "LIFX Unknown", namespace: "robheyes", author: "Robert Alan Heyes", importUrl: 'https://raw.githubusercontent.com/robheyes/lifxcode/master/LIFXWhiteMono.groovy') { 17 | capability "Bulb" 18 | capability "Polling" 19 | capability "Switch" 20 | capability "SwitchLevel" 21 | capability "Initialize" 22 | capability 'ChangeLevel' 23 | 24 | attribute "label", "string" 25 | attribute "group", "string" 26 | attribute "location", "string" 27 | 28 | command 'setWaveform', [[name: 'Waveform*', type: 'ENUM', constraints:['SAW', 'SINE', 'HALF_SINE', 'TRIANGLE', 'PULSE']], [name: 'Color*', type: 'STRING'], [name: 'Transient', type: 'ENUM', constraints: ['true', 'false']], [name: 'Period', type: 'NUMBER'], [name: 'Cycles', type: 'NUMBER'], [name: 'Skew Ratio', type: 'NUMBER']] 29 | } 30 | 31 | preferences { 32 | input "useActivityLogFlag", "bool", title: "Enable activity logging", required: false 33 | input "useDebugActivityLogFlag", "bool", title: "Enable debug logging", required: false 34 | input "defaultTransition", "decimal", title: "Transition time", description: "Set transition time (seconds)", required: true, defaultValue: 0.0 35 | input "changeLevelStep", 'decimal', title: "Change level step size", description: "", required: false, defaultValue: 1 36 | input "changeLevelEvery", 'number', title: "Change Level every x milliseconds", description: "", required: false, defaultValue: 20 37 | 38 | } 39 | } 40 | 41 | @SuppressWarnings("unused") 42 | def installed() { 43 | initialize() 44 | } 45 | 46 | @SuppressWarnings("unused") 47 | def updated() { 48 | initialize() 49 | } 50 | 51 | def initialize() { 52 | state.transitionTime = defaultTransition 53 | state.useActivityLog = useActivityLogFlag 54 | state.useActivityLogDebug = useDebugActivityLogFlag 55 | state.changeLevelEvery = changeLevelEvery 56 | state.changeLevelStep = changeLevelStep 57 | unschedule() 58 | requestInfo() 59 | runEvery1Minute poll 60 | } 61 | 62 | @SuppressWarnings("unused") 63 | def refresh() { 64 | 65 | } 66 | 67 | @SuppressWarnings("unused") 68 | def poll() { 69 | parent.lifxQuery(device, 'LIGHT.GET_STATE') { List buffer -> sendPacket buffer } 70 | } 71 | 72 | def requestInfo() { 73 | parent.lifxQuery(device, 'LIGHT.GET_STATE') { List buffer -> sendPacket buffer } 74 | } 75 | 76 | def on() { 77 | sendActions parent.deviceOnOff('on', getUseActivityLog(), state.transitionTime ?: 0) 78 | } 79 | 80 | def off() { 81 | sendActions parent.deviceOnOff('off', getUseActivityLog(), state.transitionTime ?: 0) 82 | } 83 | 84 | @SuppressWarnings("unused") 85 | def setLevel(level, duration = 0) { 86 | sendActions parent.deviceSetLevel(device, level as Number, getUseActivityLog(), duration) 87 | } 88 | 89 | @SuppressWarnings("unused") 90 | def setWaveform(String waveform, String color, String isTransient = 'true', period = 5, cycles = 3.40282346638528860e38, skew = 0.5) { 91 | sendActions parent.deviceSetWaveform(device, isTransient.toBoolean(), stringToMap(color), period.toInteger(), cycles.toFloat(), skew.toFloat(), waveform) 92 | } 93 | 94 | @SuppressWarnings("unused") 95 | def startLevelChange(direction) { 96 | // logDebug "startLevelChange called with $direction" 97 | enableLevelChange() 98 | if (changeLevelStep && changeLevelEvery) { 99 | doLevelChange(direction == 'up' ? 1 : -1) 100 | } else { 101 | logDebug "No parameters" 102 | } 103 | } 104 | 105 | @SuppressWarnings("unused") 106 | def stopLevelChange() { 107 | sendEvent([name: "cancelLevelChange", value: 'yes', displayed: false]) 108 | } 109 | 110 | def enableLevelChange() { 111 | sendEvent([name: "cancelLevelChange", value: 'no', displayed: false]) 112 | } 113 | 114 | def doLevelChange(direction) { 115 | def cancelling = device.currentValue('cancelLevelChange') ?: 'no' 116 | if (cancelling == 'yes') { 117 | runInMillis 2 * (changeLevelEvery as Integer), "enableLevelChange" 118 | return; 119 | } 120 | def newLevel = device.currentValue('level') + ((direction as Float) * (changeLevelStep as Float)) 121 | def lastStep = false 122 | if (newLevel < 0) { 123 | newLevel = 0 124 | lastStep = true 125 | } else if (newLevel > 100) { 126 | newLevel = 100 127 | lastStep = true 128 | } 129 | sendActions parent.deviceSetLevel(device, newLevel, getUseActivityLog(), (changeLevelEvery - 1) / 1000) 130 | if (!lastStep) { 131 | runInMillis changeLevelEvery as Integer, "doLevelChange", [data: direction] 132 | } 133 | } 134 | 135 | private void sendActions(Map actions) { 136 | actions.commands?.each { item -> parent.lifxCommand(device, item.cmd, item.payload) { List buffer -> sendPacket buffer, true } } 137 | actions.events?.each { sendEvent it } 138 | } 139 | 140 | def parse(String description) { 141 | List events = parent.parseForDevice(device, description, getUseActivityLog()) 142 | events.collect { createEvent(it) } 143 | } 144 | 145 | private def myIp() { 146 | device.getDeviceNetworkId() 147 | } 148 | 149 | private void sendPacket(List buffer, boolean noResponseExpected = false) { 150 | String stringBytes = hubitat.helper.HexUtils.byteArrayToHexString parent.asByteArray(buffer) 151 | sendHubCommand( 152 | new hubitat.device.HubAction( 153 | stringBytes, 154 | hubitat.device.Protocol.LAN, 155 | [ 156 | type : hubitat.device.HubAction.Type.LAN_TYPE_UDPCLIENT, 157 | destinationAddress: myIp() + ":56700", 158 | encoding : hubitat.device.HubAction.Encoding.HEX_STRING, 159 | ignoreResponse : noResponseExpected 160 | ] 161 | ) 162 | ) 163 | } 164 | 165 | def getUseActivityLog() { 166 | if (state.useActivityLog == null) { 167 | state.useActivityLog = true 168 | } 169 | return state.useActivityLog 170 | } 171 | 172 | def setUseActivityLog(value) { 173 | log.debug("Setting useActivityLog to ${value ? 'true' : 'false'}") 174 | state.useActivityLog = value 175 | } 176 | 177 | def getUseActivityLogDebug() { 178 | if (state.useActivityLogDebug == null) { 179 | state.useActivityLogDebug = false 180 | } 181 | return state.useActivityLogDebug 182 | } 183 | 184 | def setUseActivityLogDebug(value) { 185 | log.debug("Setting useActivityLogDebug to ${value ? 'true' : 'false'}") 186 | state.useActivityLogDebug = value 187 | } 188 | 189 | void logDebug(msg) { 190 | if (getUseActivityLogDebug()) { 191 | log.debug msg 192 | } 193 | } 194 | 195 | void logInfo(msg) { 196 | if (getUseActivityLog()) { 197 | log.info msg 198 | } 199 | } 200 | 201 | void logWarn(String msg) { 202 | if (getUseActivityLog()) { 203 | log.warn msg 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /LIFXWhite.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Copyright 2019 Robert Heyes. All Rights Reserved 4 | * 5 | * This software is free for Private Use. You may use and modify the software without distributing it. 6 | * If you make a fork, and add new code, then you should create a pull request to add value, there is no 7 | * guarantee that your pull request will be merged. 8 | * 9 | * You may not grant a sublicense to modify and distribute this software to third parties without permission 10 | * from the copyright holder 11 | * Software is provided without warranty and your use of it is at your own risk. 12 | * 13 | */ 14 | 15 | metadata { 16 | definition(name: "LIFX White", namespace: "robheyes", author: "Robert Alan Heyes", importUrl: 'https://raw.githubusercontent.com/robheyes/lifxcode/master/LIFXWhite.groovy') { 17 | capability "Bulb" 18 | capability "ColorTemperature" 19 | capability "Polling" 20 | capability "Switch" 21 | capability "SwitchLevel" 22 | capability "Initialize" 23 | capability 'ChangeLevel' 24 | 25 | attribute "label", "string" 26 | attribute "group", "string" 27 | attribute "location", "string" 28 | 29 | 30 | command 'setWaveform', [[name: 'Waveform*', type: 'ENUM', constraints:['SAW', 'SINE', 'HALF_SINE', 'TRIANGLE', 'PULSE']], [name: 'Color*', type: 'STRING'], [name: 'Transient', type: 'ENUM', constraints: ['true', 'false']], [name: 'Period', type: 'NUMBER'], [name: 'Cycles', type: 'NUMBER'], [name: 'Skew Ratio', type: 'NUMBER']] 31 | } 32 | 33 | preferences { 34 | input "useActivityLogFlag", "bool", title: "Enable activity logging", required: false 35 | input "useDebugActivityLogFlag", "bool", title: "Enable debug logging", required: false 36 | input "defaultTransition", "decimal", title: "Transition time", description: "Set transition time (seconds)", required: true, defaultValue: 0.0 37 | input "changeLevelStep", 'decimal', title: "Change level step size", description: "", required: false, defaultValue: 1 38 | input "changeLevelEvery", 'number', title: "Change Level every x milliseconds", description: "", required: false, defaultValue: 20 39 | 40 | } 41 | } 42 | 43 | @SuppressWarnings("unused") 44 | def installed() { 45 | initialize() 46 | } 47 | 48 | @SuppressWarnings("unused") 49 | def updated() { 50 | state.transitionTime = defaultTransition 51 | initialize() 52 | } 53 | 54 | def initialize() { 55 | state.transitionTime = defaultTransition 56 | state.useActivityLog = useActivityLogFlag 57 | state.useActivityLogDebug = useDebugActivityLogFlag 58 | state.changeLevelEvery = changeLevelEvery 59 | state.changeLevelStep = changeLevelStep 60 | unschedule() 61 | requestInfo() 62 | runEvery1Minute poll 63 | } 64 | 65 | @SuppressWarnings("unused") 66 | def refresh() { 67 | 68 | } 69 | 70 | @SuppressWarnings("unused") 71 | def poll() { 72 | parent.lifxQuery(device, 'LIGHT.GET_STATE') { List buffer -> sendPacket buffer } 73 | } 74 | 75 | def requestInfo() { 76 | parent.lifxQuery(device, 'LIGHT.GET_STATE') { List buffer -> sendPacket buffer } 77 | } 78 | 79 | def on() { 80 | sendActions parent.deviceOnOff('on', getUseActivityLog(), state.transitionTime ?: 0) 81 | } 82 | 83 | def off() { 84 | sendActions parent.deviceOnOff('off', getUseActivityLog(), state.transitionTime ?: 0) 85 | } 86 | 87 | @SuppressWarnings("unused") 88 | def setColorTemperature(temperature, level = null, transitionTime = null) { 89 | sendActions parent.deviceSetColorTemperature(device, temperature, level, getUseActivityLog(), transitionTime ?: (state.transitionTime ?: 0)) 90 | } 91 | 92 | @SuppressWarnings("unused") 93 | def setLevel(level, duration = 0) { 94 | sendActions parent.deviceSetLevel(device, level as Number, getUseActivityLog(), duration) 95 | } 96 | 97 | def setWaveform(String waveform, String color, String isTransient = 'true', period = 5, cycles = 3.40282346638528860e38, skew = 0.5) { 98 | sendActions parent.deviceSetWaveform(device, isTransient.toBoolean(), stringToMap(color), period.toInteger(), cycles.toFloat(), skew.toFloat(), waveform) 99 | } 100 | 101 | @SuppressWarnings("unused") 102 | def startLevelChange(direction) { 103 | // logDebug "startLevelChange called with $direction" 104 | enableLevelChange() 105 | if (changeLevelStep && changeLevelEvery) { 106 | doLevelChange(direction == 'up' ? 1 : -1) 107 | } else { 108 | logDebug "No parameters" 109 | } 110 | } 111 | 112 | @SuppressWarnings("unused") 113 | def stopLevelChange() { 114 | sendEvent([name: "cancelLevelChange", value: 'yes', displayed: false]) 115 | } 116 | 117 | def enableLevelChange() { 118 | sendEvent([name: "cancelLevelChange", value: 'no', displayed: false]) 119 | } 120 | 121 | def doLevelChange(direction) { 122 | def cancelling = device.currentValue('cancelLevelChange') ?: 'no' 123 | if (cancelling == 'yes') { 124 | runInMillis 2 * (changeLevelEvery as Integer), "enableLevelChange" 125 | return; 126 | } 127 | def newLevel = device.currentValue('level') + ((direction as Float) * (changeLevelStep as Float)) 128 | def lastStep = false 129 | if (newLevel < 0) { 130 | newLevel = 0 131 | lastStep = true 132 | } else if (newLevel > 100) { 133 | newLevel = 100 134 | lastStep = true 135 | } 136 | sendActions parent.deviceSetLevel(device, newLevel, getUseActivityLog(), (changeLevelEvery - 1) / 1000) 137 | if (!lastStep) { 138 | runInMillis changeLevelEvery as Integer, "doLevelChange", [data: direction] 139 | } 140 | } 141 | private void sendActions(Map actions) { 142 | actions.commands?.each { item -> parent.lifxCommand(device, item.cmd, item.payload) { List buffer -> sendPacket buffer, true } } 143 | actions.events?.each { sendEvent it } 144 | } 145 | 146 | def parse(String description) { 147 | List events = parent.parseForDevice(device, description, getUseActivityLog()) 148 | events.collect { createEvent(it) } 149 | } 150 | 151 | private def myIp() { 152 | device.getDeviceNetworkId() 153 | } 154 | 155 | private void sendPacket(List buffer, boolean noResponseExpected = false) { 156 | String stringBytes = hubitat.helper.HexUtils.byteArrayToHexString parent.asByteArray(buffer) 157 | sendHubCommand( 158 | new hubitat.device.HubAction( 159 | stringBytes, 160 | hubitat.device.Protocol.LAN, 161 | [ 162 | type : hubitat.device.HubAction.Type.LAN_TYPE_UDPCLIENT, 163 | destinationAddress: myIp() + ":56700", 164 | encoding : hubitat.device.HubAction.Encoding.HEX_STRING, 165 | ignoreResponse : noResponseExpected 166 | ] 167 | ) 168 | ) 169 | } 170 | 171 | def getUseActivityLog() { 172 | if (state.useActivityLog == null) { 173 | state.useActivityLog = true 174 | } 175 | return state.useActivityLog 176 | } 177 | 178 | def setUseActivityLog(value) { 179 | log.debug("Setting useActivityLog to ${value ? 'true':'false'}") 180 | state.useActivityLog = value 181 | } 182 | 183 | def getUseActivityLogDebug() { 184 | if (state.useActivityLogDebug == null) { 185 | state.useActivityLogDebug = false 186 | } 187 | return state.useActivityLogDebug 188 | } 189 | 190 | def setUseActivityLogDebug(value) { 191 | log.debug("Setting useActivityLogDebug to ${value ? 'true':'false'}") 192 | state.useActivityLogDebug = value 193 | } 194 | 195 | void logDebug(msg) { 196 | if (getUseActivityLogDebug()) { 197 | log.debug msg 198 | } 199 | } 200 | 201 | void logInfo(msg) { 202 | if (getUseActivityLog()) { 203 | log.info msg 204 | } 205 | } 206 | 207 | void logWarn(String msg) { 208 | if (getUseActivityLog()) { 209 | log.warn msg 210 | } 211 | } -------------------------------------------------------------------------------- /LIFXWhiteMono.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Copyright 2019 Robert Heyes. All Rights Reserved 4 | * 5 | * This software is free for Private Use. You may use and modify the software without distributing it. 6 | * If you make a fork, and add new code, then you should create a pull request to add value, there is no 7 | * guarantee that your pull request will be merged. 8 | * 9 | * You may not grant a sublicense to modify and distribute this software to third parties without permission 10 | * from the copyright holder 11 | * Software is provided without warranty and your use of it is at your own risk. 12 | * 13 | */ 14 | 15 | metadata { 16 | definition(name: "LIFX White Mono", namespace: "robheyes", author: "Robert Alan Heyes", importUrl: 'https://raw.githubusercontent.com/robheyes/lifxcode/master/LIFXWhiteMono.groovy') { 17 | capability "Bulb" 18 | capability "Polling" 19 | capability "Switch" 20 | capability "SwitchLevel" 21 | capability "Initialize" 22 | capability 'ChangeLevel' 23 | 24 | attribute "label", "string" 25 | attribute "group", "string" 26 | attribute "location", "string" 27 | 28 | command 'setWaveform', [[name: 'Waveform*', type: 'ENUM', constraints:['SAW', 'SINE', 'HALF_SINE', 'TRIANGLE', 'PULSE']], [name: 'Color*', type: 'STRING'], [name: 'Transient', type: 'ENUM', constraints: ['true', 'false']], [name: 'Period', type: 'NUMBER'], [name: 'Cycles', type: 'NUMBER'], [name: 'Skew Ratio', type: 'NUMBER']] 29 | } 30 | 31 | preferences { 32 | input "useActivityLogFlag", "bool", title: "Enable activity logging", required: false 33 | input "useDebugActivityLogFlag", "bool", title: "Enable debug logging", required: false 34 | input "defaultTransition", "decimal", title: "Transition time", description: "Set transition time (seconds)", required: true, defaultValue: 0.0 35 | input "changeLevelStep", 'decimal', title: "Change level step size", description: "", required: false, defaultValue: 1 36 | input "changeLevelEvery", 'number', title: "Change Level every x milliseconds", description: "", required: false, defaultValue: 20 37 | 38 | } 39 | } 40 | 41 | @SuppressWarnings("unused") 42 | def installed() { 43 | initialize() 44 | } 45 | 46 | @SuppressWarnings("unused") 47 | def updated() { 48 | initialize() 49 | } 50 | 51 | def initialize() { 52 | state.transitionTime = defaultTransition 53 | state.useActivityLog = useActivityLogFlag 54 | state.useActivityLogDebug = useDebugActivityLogFlag 55 | state.changeLevelEvery = changeLevelEvery 56 | state.changeLevelStep = changeLevelStep 57 | unschedule() 58 | requestInfo() 59 | runEvery1Minute poll 60 | } 61 | 62 | @SuppressWarnings("unused") 63 | def refresh() { 64 | 65 | } 66 | 67 | @SuppressWarnings("unused") 68 | def poll() { 69 | parent.lifxQuery(device, 'LIGHT.GET_STATE') { List buffer -> sendPacket buffer } 70 | } 71 | 72 | def requestInfo() { 73 | parent.lifxQuery(device, 'LIGHT.GET_STATE') { List buffer -> sendPacket buffer } 74 | } 75 | 76 | def on() { 77 | sendActions parent.deviceOnOff('on', getUseActivityLog(), state.transitionTime ?: 0) 78 | } 79 | 80 | def off() { 81 | sendActions parent.deviceOnOff('off', getUseActivityLog(), state.transitionTime ?: 0) 82 | } 83 | 84 | @SuppressWarnings("unused") 85 | def setLevel(level, duration = 0) { 86 | sendActions parent.deviceSetLevel(device, level as Number, getUseActivityLog(), duration) 87 | } 88 | 89 | @SuppressWarnings("unused") 90 | def setWaveform(String waveform, String color, String isTransient = 'true', period = 5, cycles = 3.40282346638528860e38, skew = 0.5) { 91 | sendActions parent.deviceSetWaveform(device, isTransient.toBoolean(), stringToMap(color), period.toInteger(), cycles.toFloat(), skew.toFloat(), waveform) 92 | } 93 | 94 | @SuppressWarnings("unused") 95 | def startLevelChange(direction) { 96 | // logDebug "startLevelChange called with $direction" 97 | enableLevelChange() 98 | if (changeLevelStep && changeLevelEvery) { 99 | doLevelChange(direction == 'up' ? 1 : -1) 100 | } else { 101 | logDebug "No parameters" 102 | } 103 | } 104 | 105 | @SuppressWarnings("unused") 106 | def stopLevelChange() { 107 | sendEvent([name: "cancelLevelChange", value: 'yes', displayed: false]) 108 | } 109 | 110 | def enableLevelChange() { 111 | sendEvent([name: "cancelLevelChange", value: 'no', displayed: false]) 112 | } 113 | 114 | def doLevelChange(direction) { 115 | def cancelling = device.currentValue('cancelLevelChange') ?: 'no' 116 | if (cancelling == 'yes') { 117 | runInMillis 2 * (changeLevelEvery as Integer), "enableLevelChange" 118 | return; 119 | } 120 | def newLevel = device.currentValue('level') + ((direction as Float) * (changeLevelStep as Float)) 121 | def lastStep = false 122 | if (newLevel < 0) { 123 | newLevel = 0 124 | lastStep = true 125 | } else if (newLevel > 100) { 126 | newLevel = 100 127 | lastStep = true 128 | } 129 | sendActions parent.deviceSetLevel(device, newLevel, getUseActivityLog(), (changeLevelEvery - 1) / 1000) 130 | if (!lastStep) { 131 | runInMillis changeLevelEvery as Integer, "doLevelChange", [data: direction] 132 | } 133 | } 134 | 135 | private void sendActions(Map actions) { 136 | actions.commands?.each { item -> parent.lifxCommand(device, item.cmd, item.payload) { List buffer -> sendPacket buffer, true } } 137 | actions.events?.each { sendEvent it } 138 | } 139 | 140 | def parse(String description) { 141 | List events = parent.parseForDevice(device, description, getUseActivityLog()) 142 | events.collect { createEvent(it) } 143 | } 144 | 145 | private def myIp() { 146 | device.getDeviceNetworkId() 147 | } 148 | 149 | private void sendPacket(List buffer, boolean noResponseExpected = false) { 150 | String stringBytes = hubitat.helper.HexUtils.byteArrayToHexString parent.asByteArray(buffer) 151 | sendHubCommand( 152 | new hubitat.device.HubAction( 153 | stringBytes, 154 | hubitat.device.Protocol.LAN, 155 | [ 156 | type : hubitat.device.HubAction.Type.LAN_TYPE_UDPCLIENT, 157 | destinationAddress: myIp() + ":56700", 158 | encoding : hubitat.device.HubAction.Encoding.HEX_STRING, 159 | ignoreResponse : noResponseExpected 160 | ] 161 | ) 162 | ) 163 | } 164 | 165 | def getUseActivityLog() { 166 | if (state.useActivityLog == null) { 167 | state.useActivityLog = true 168 | } 169 | return state.useActivityLog 170 | } 171 | 172 | def setUseActivityLog(value) { 173 | log.debug("Setting useActivityLog to ${value ? 'true' : 'false'}") 174 | state.useActivityLog = value 175 | } 176 | 177 | def getUseActivityLogDebug() { 178 | if (state.useActivityLogDebug == null) { 179 | state.useActivityLogDebug = false 180 | } 181 | return state.useActivityLogDebug 182 | } 183 | 184 | def setUseActivityLogDebug(value) { 185 | log.debug("Setting useActivityLogDebug to ${value ? 'true' : 'false'}") 186 | state.useActivityLogDebug = value 187 | } 188 | 189 | void logDebug(msg) { 190 | if (getUseActivityLogDebug()) { 191 | log.debug msg 192 | } 193 | } 194 | 195 | void logInfo(msg) { 196 | if (getUseActivityLog()) { 197 | log.info msg 198 | } 199 | } 200 | 201 | void logWarn(String msg) { 202 | if (getUseActivityLog()) { 203 | log.warn msg 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /LIFX_Logo_Square_Black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robheyes/lifxcode/766e7bff8557a0bd475b3b45f1a5d5ccf062c1c1/LIFX_Logo_Square_Black.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LIFX Lan Protocol app and drivers for Hubitat Elevation 2 | ## About 3 | LIFX is quite a complex system to integrate into Hubitat so it's not as simple as just installing a single driver 4 | 5 | There are several components 6 | * The LIFX Master App - this is where you discover devices 7 | * Various LIFX device handlers 8 | * LIFX Color 9 | * LIFXPlus Color 10 | * LIFX White 11 | * LIFX Day and Dusk 12 | * LIFX Tile - dummy driver, only supports on/off 13 | * LIFX Multizone - for Beam and Strip 14 | 15 | # Installation 16 | ## Using Hubitat Package Manager 17 | This is now the preferred installation method since it provides an easy path to install updates and beta releases. 18 | 19 | See https://github.com/dcmeglio/hubitat-packagemanager if you don't already have Hubitat Package Manager installed. 20 | 21 | ### New installation using HPM 22 | * Open the HPM app and choose __Install__ 23 | * Pick `Browse by Tags` 24 | * Select `Lights & Switches` 25 | * Scroll down to `LIFX Master app and drivers` and select it 26 | * Press the __Next__ button 27 | 28 | HPM should then do some magic and install everything for you. 29 | You'll probably still need to follow the `Create the app` step below. 30 | ### HPM installation when you have previously manually installed 31 | Simply use the __Match Up__ option in HPM. I'd recommend that you leave the `Assume that packages are up-to-date` switch 32 | turned off. 33 | 34 | Then do an __Update__ to ensure that you have the latest version. 35 | 36 | ## Manual installation 37 | ### LIFX Master App 38 | On the Apps Code page, press the **New App** button 39 | 40 | Paste the code for `LIFXMasterApp.groovy` and press the **Save** button (or type **Ctrl-S**) 41 | ### Individual device handlers 42 | On the Devices Code page, press the **New Driver** button 43 | 44 | Paste the code for `LIFXColor.groovy` and save 45 | 46 | Repeat this process for each of the device handlers you want to install. It's probably best 47 | to add all of them even if you don't have a corresponding device at the moment, I've found that buying 48 | LIFX devices is a bit addictive. 49 | 50 | ## Create the app 51 | On the Apps page press the **Add User App** button then click on **LIFX Master** in the list of available apps. 52 | 53 | ####IMPORTANT: don't forget to press the __Done__ button to make sure the the **LIFX Master** app sticks around. 54 | 55 | ## Device discovery 56 | First of all, make sure that all your LIFX devices are powered on, obviously discovery won't find any device that doesn't 57 | have power. 58 | 59 | Again __IMPORTANT__ if you didn't press the __Done__ button when you created the **LIFX Master** app then do it now. 60 | 61 | Open the **LIFX Master** app and press the **Discover devices** button. 62 | You may notice the **LIFX Discovery** device in your list of devices, but this should disappear at the end of the scan. 63 | 64 | ### Updating 65 | #### Always make a backup. 66 | 67 | #### Hubitat Package Manager update 68 | * Open the HPM app 69 | * Select the `Update` option 70 | * If there's an available update then you should see the new version in the list of updated packages. 71 | 72 | #### Manual update 73 | When the drivers/app have been updated I'll post a message on the Hubitat forum. 74 | 75 | To update a device or the app just open the corresponding code, 76 | 1. click the `Import` button at the top of the window 77 | 2. a dialog should open with the URL prefilled - click its `Import` button 78 | 3. click `OK` on the override dialog that should have appeared 79 | 4. click the `Save` button 80 | 81 | I normally open the code for each app and device driver in a new tab, then for each tab I'll just do the import (steps 1-3) 82 | then once the code has been updated I'll go through the tabs one at a time clicking save and then closing that tab. 83 | 84 | While this is happening, you may find some errors appearing in the log if I've made significant changes. Only be 85 | concerned if you see errors after all the drivers and the app have been updated. 86 | 87 | ### Implementation information 88 | The LIFX Lan Protocol uses UDP messages, it seems that sometimes these can be missed or lost, so the discovery process 89 | makes 5 passes over your local network to try to mitigate this. So far this seems good enough on my network. 90 | 91 | Further to this, when a command is sent to change a LIFX setting it requests an acknowledgement and 92 | the sequence number is tracked with the LAN Protocol packet. When a subsequent command or query is sent 93 | it checks to see whether the acknowledgement was received. If it wasn't received the packet is sent 94 | one more time. The reason for this is that UDP messages are not guaranteed to arrive, so it's pretty much 95 | a small safety net. You can expect that this will happen at least when the device is next polled (under a minute). 96 | 97 | ### Using the Multizone capabilities 98 | The multizone driver provides a generic `setZones` command. This accepts an input in the following format: 99 | ```0:"[hue: 30, brightness: 100, saturation: 100, kelvin: 3500]", 1:"[hue: 0, brightness: 100, saturation: 0, kelvin:3500"]``` 100 | an entry can be submitted for each zone you want to update, and can contain any combination of parameters - 101 | e.g. if you only want to update hue and saturation, you can specify only those values, and the brighness and kelvin 102 | values for that zone will remain the same. Likewise, you can omit any zones that you do not want to update. 103 | As an alternate to specific HBSK values, you can specify any of the defined `namedColors` or an RGB hex value using 104 | ```0:"[color: 'Red']", 1:"[color: '#0000FF']"``` 105 | 106 | An additional capability is the creation of child devices for each zone. The corresponding child device can be 107 | updated/set like a typical RGBW bulb, and it will update/set the zone in the parent multizone device. If the parent 108 | device is updated directly, it will update its children on the next polling interval (1 min). 109 | 110 | NOTES: 111 | * After creating the child devices, I've found you have to toggle a few things before they start updating 112 | the parent properly - e.g. flip the "switch" on, off, and on again. Will see if this can be addressed in a 113 | future update. 114 | 115 | ## Troubleshooting 116 | ### Undiscovered devices 117 | If you find that some devices aren't discovered 118 | * try the **Discover new only new devices** button 119 | * make sure that the missing devices still have power - someone may have turned off a switch :) 120 | * try altering some of the options as described below. 121 | 122 | With recent improvements I've found that the default settings should be more than enough to discover all your devices in two 123 | passes, but your mileage may vary. 124 | 125 | My first recommendation would be increasing the value of __`Time between commands (milliseconds)`__. 126 | Try increasing it by 10 at a time. 127 | 128 | You can also increase `Maximum number of passes` to give discovery a better chance of reaching all the devices in your network. 129 | I've found that all my devices are found in the first pass at least 95% of the time. 130 | 131 | ### Errors in the log 132 | Let me know if you see any errors in the log, and I'll do my best to fix the issue as soon as possible 133 | 134 | ### Limitations 135 | I have assumed that you will be running a Class C network (also known as /24), ie that your subnet mask is 255.255.255.0, 136 | which limits you to a total of 254 IP addresses. 137 | 138 | Note that this uses the IP address as the device network id - if you change the id you may break things. 139 | If you don't assign fixed IP addresses to your LIFX devices then you may be able to use this to avoid having 140 | to scan for new devices. 141 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'groovy' 3 | id 'java' 4 | id 'org.jetbrains.intellij' version '0.3.12' 5 | } 6 | 7 | group 'robteifi' 8 | version '1.0-SNAPSHOT' 9 | 10 | sourceCompatibility = 1.8 11 | 12 | repositories { 13 | mavenCentral() 14 | "http://m2repo.spockframework.org/ext/" 15 | "http://m2repo.spockframework.org/snapshots/" 16 | } 17 | 18 | dependencies { 19 | compile 'org.codehaus.groovy:groovy-all:2.4.15' 20 | 21 | testCompile "org.spockframework:spock-core:1.2-groovy-2.4" 22 | testCompile "org.hamcrest:hamcrest-core:1.3" // only necessary if Hamcrest matchers are used 23 | testRuntime "net.bytebuddy:byte-buddy:1.8.21" // allows mocking of classes (in addition to interfaces) 24 | testRuntime "org.objenesis:objenesis:2.6" // allows mocking of classes without default constructor (together with CGLIB) 25 | testCompile group: 'junit', name: 'junit', version: '4.12' 26 | } 27 | 28 | intellij { 29 | version '2018.3' 30 | } 31 | patchPluginXml { 32 | changeNotes """ 33 | Add change notes here.
34 | most HTML tags may be used""" 35 | } -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robheyes/lifxcode/766e7bff8557a0bd475b3b45f1a5d5ccf062c1c1/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sat Dec 15 10:25:34 GMT 2018 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-4.10-all.zip 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /packageManifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageName": "LIFX Master", 3 | "author": "Robert Alan Heyes", 4 | "version": "1.0.6", 5 | "minimumHEVersion": "2.2.8", 6 | "dateReleased": "2021-07-18", 7 | "apps": [ 8 | { 9 | "id": "943ac10e-f770-499a-8921-7899ea06091d", 10 | "name": "LIFX Master", 11 | "namespace": "robheyes", 12 | "location": "https://raw.githubusercontent.com/robheyes/lifxcode/master/LIFXMasterApp.groovy", 13 | "required": true, 14 | "oauth": false, 15 | "primary": true 16 | } 17 | ], 18 | "drivers": [ 19 | { 20 | "id": "aa6e0abc-efe7-4eb6-9090-2310b6a74837", 21 | "name": "LIFX Color", 22 | "namespace": "robheyes", 23 | "location": "https://raw.githubusercontent.com/robheyes/lifxcode/master/LIFXColor.groovy", 24 | "required": false 25 | }, 26 | { 27 | "id": "04b16494-f20c-4f5c-8819-21b1ac7660e4", 28 | "name": "LIFX Day and Dusk", 29 | "namespace": "robheyes", 30 | "location": "https://raw.githubusercontent.com/robheyes/lifxcode/master/LIFXDayAndDusk.groovy", 31 | "required": false 32 | }, 33 | { 34 | "id": "66e3b9bc-6c0e-4969-a2f6-36b81d71d0dd", 35 | "name": "LIFX MultiZone", 36 | "namespace": "robheyes", 37 | "location": "https://raw.githubusercontent.com/robheyes/lifxcode/master/LIFXMultiZone.groovy", 38 | "required": false 39 | }, 40 | { 41 | "id": "184efd1b-690c-4d40-8add-768822c3feed", 42 | "name": "LIFX Multizone Child", 43 | "namespace": "robheyes", 44 | "location": "https://raw.githubusercontent.com/robheyes/lifxcode/master/LIFXMultiZoneChild.groovy", 45 | "required": false 46 | }, 47 | { 48 | "id": "82aa2d34-8a99-4c77-bffb-d23f357b0c99", 49 | "name": "LIFXPlus Color", 50 | "namespace": "robheyes", 51 | "location": "https://raw.githubusercontent.com/robheyes/lifxcode/master/LIFXPlusColor.groovy", 52 | "required": false 53 | }, 54 | { 55 | "id": "2c053a3f-cf49-43d9-bdc0-40e2586fe338", 56 | "name": "LIFX Tile", 57 | "namespace": "robheyes", 58 | "location": "https://raw.githubusercontent.com/robheyes/lifxcode/master/LIFXTile.groovy", 59 | "required": false 60 | }, 61 | { 62 | "id": "917b6f3e-aa37-4a1d-bce0-b25fbd22a0d5", 63 | "name": "LIFX White", 64 | "namespace": "robheyes", 65 | "location": "https://raw.githubusercontent.com/robheyes/lifxcode/master/LIFXWhite.groovy", 66 | "required": false 67 | }, 68 | { 69 | "id": "6d756a3a-54e5-4440-883c-51719a0aa5ab", 70 | "name": "LIFX White Mono", 71 | "namespace": "robheyes", 72 | "location": "https://raw.githubusercontent.com/robheyes/lifxcode/master/LIFXWhiteMono.groovy", 73 | "required": false 74 | }, 75 | { 76 | "id": "a7ba55c1-d8ff-4fe7-a1a3-7100386ebfde", 77 | "name": "LIFX Unknown", 78 | "namespace": "robheyes", 79 | "location": "https://raw.githubusercontent.com/robheyes/lifxcode/master/LIFXUnknown.groovy", 80 | "required": true 81 | } 82 | ] 83 | } -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'lifx' 2 | 3 | -------------------------------------------------------------------------------- /src/main/groovy/Buffer.groovy: -------------------------------------------------------------------------------- 1 | class Buffer { 2 | private theBuffer = [] 3 | 4 | def size() { 5 | theBuffer.size() 6 | } 7 | 8 | def contents() { 9 | asByteList(theBuffer) 10 | } 11 | 12 | private List asByteList(List buffer) { 13 | buffer.each { it as byte } 14 | } 15 | 16 | List add(List buffer, Byte value) { 17 | buffer.add(Byte.toUnsignedInt(value)) 18 | return buffer 19 | } 20 | 21 | List add(List buffer, short value) { 22 | def lower = value & 0xff 23 | add(buffer, lower as byte) 24 | add(buffer, ((value - lower) >>> 8) as byte) 25 | } 26 | 27 | List add(List buffer, int value) { 28 | def lower = value & 0xffff 29 | add(buffer, lower as short) 30 | add(buffer, Integer.divideUnsigned(value - lower, 0x10000) as short) 31 | } 32 | 33 | List add(List buffer, long value) { 34 | def lower = value & 0xffffffff 35 | add(buffer, lower as int) 36 | add(buffer, Long.divideUnsigned(value - lower, 0x100000000) as int) 37 | } 38 | 39 | List add(List buffer, byte[] values) { 40 | for (value in values) { 41 | add(buffer, value) 42 | } 43 | return buffer 44 | } 45 | 46 | void fill(List buffer, byte value, int count) { 47 | for (int i = 0; i < count; i++) { 48 | add(buffer, value) 49 | } 50 | } 51 | 52 | def add(Byte value) { 53 | add(theBuffer, value) 54 | } 55 | 56 | def add(Short value) { 57 | add(theBuffer, value) 58 | } 59 | 60 | def add(Integer value) { 61 | add(theBuffer, value) 62 | } 63 | 64 | def add(Long value) { 65 | def buffer = theBuffer 66 | add(buffer, value) 67 | } 68 | 69 | def add(byte[] values) { 70 | add(theBuffer, values) 71 | } 72 | 73 | def add(Buffer buffer) { 74 | add(theBuffer, buffer.contents().toArray() as byte[]) 75 | } 76 | 77 | def addByteCopies(byte value, int count) { 78 | def buffer = theBuffer 79 | fill(buffer, value, count) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/main/groovy/FrameAddress.groovy: -------------------------------------------------------------------------------- 1 | class FrameAddress { 2 | private final long target 3 | private final boolean ackRequired 4 | private final boolean responseRequired 5 | private final byte sequenceNumber 6 | 7 | FrameAddress(long target, boolean ackRequired, boolean responseRequired, byte sequenceNumber) { 8 | this.sequenceNumber = sequenceNumber 9 | this.responseRequired = responseRequired 10 | this.ackRequired = ackRequired 11 | this.target = target 12 | } 13 | 14 | def addToBuffer(Buffer buffer) { 15 | buffer.add(target) 16 | buffer.addByteCopies(0 as byte, 6) 17 | buffer.add(((ackRequired ? 0x02 : 0) | (responseRequired ? 0x01 : 0)) as byte) 18 | buffer.add(sequenceNumber) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/groovy/Parser.groovy: -------------------------------------------------------------------------------- 1 | class Parser { 2 | private final List descriptor 3 | 4 | Parser(String descriptor) { 5 | this.descriptor = descriptor.findAll(~/(\w+):(\d+)([aAbBlLsS]?)/) { 6 | full -> 7 | [ 8 | endian: full[3].toUpperCase(), 9 | bytes : full[2], 10 | name : full[1], 11 | ] 12 | } 13 | } 14 | 15 | 16 | Map parse(List bytes) { 17 | Map result = new HashMap(); 18 | int offset = 0 19 | descriptor.each { item -> 20 | int nextOffset = offset + (item.bytes as int) 21 | 22 | def data = bytes.subList(offset, nextOffset) 23 | offset = nextOffset 24 | // assume big endian for now 25 | if ('A' == item.endian) { 26 | result.put(item.name, data) 27 | return 28 | } 29 | if ('S' == item.endian) { 30 | result.put(item.name, new String(data as byte[])) 31 | return 32 | } 33 | if ('B' != item.endian) { 34 | data = data.reverse() 35 | } 36 | 37 | BigInteger value = 0 38 | data.each { value = (value * 256) + (it & 0xff) } 39 | switch (item.bytes) { 40 | case 1: 41 | result.put(item.name, (value & 0xFF) as byte) 42 | break 43 | case 2: 44 | result.put(item.name, (value & 0xFFFF) as short) 45 | break 46 | case 3: case 4: 47 | result.put(item.name, (value & 0xFFFFFFFF) as int) 48 | break 49 | default: 50 | result.put(item.name, (value & 0xFFFFFFFFFFFFFFFF) as long) 51 | } 52 | } 53 | if (offset < bytes.size()) { 54 | result.put('remainder', bytes[offset..-1]) 55 | } 56 | return result 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/main/groovy/ProtocolHeader.groovy: -------------------------------------------------------------------------------- 1 | class ProtocolHeader { 2 | 3 | private final short messageType 4 | 5 | ProtocolHeader(short messageType) { 6 | this.messageType = messageType 7 | } 8 | 9 | def fillBuffer(Buffer theBuffer) { 10 | theBuffer.addByteCopies(0 as byte, 8) 11 | theBuffer.add(messageType) 12 | theBuffer.add(0 as short) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/groovy/ShortFrame.groovy: -------------------------------------------------------------------------------- 1 | class ShortFrame { 2 | private final boolean tagged 3 | private final int source 4 | 5 | ShortFrame(boolean tagged, int source) { 6 | this.source = source 7 | this.tagged = tagged 8 | } 9 | 10 | def fillBuffer(Buffer buffer) { 11 | buffer.add(0x00 as byte) 12 | buffer.add((tagged ? 0x34 : 0x14) as byte) 13 | buffer.add(source) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/plugin.xml: -------------------------------------------------------------------------------- 1 | 2 | robteifi.lifx 3 | Plugin display name here 4 | YourCompany 5 | 6 | 8 | most HTML tags may be used 9 | ]]> 10 | 11 | 13 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/test/groovy/BufferTest.groovy: -------------------------------------------------------------------------------- 1 | import spock.lang.Specification 2 | 3 | 4 | class BufferTest extends Specification { 5 | private Buffer buffer 6 | 7 | def setup() { 8 | buffer = new Buffer() 9 | } 10 | 11 | def "New buffer is empty"() { 12 | when: 13 | def size = buffer.size() 14 | then: 15 | size == 0 16 | } 17 | 18 | def "New buffer has empty contents"() { 19 | when: 20 | def content = buffer.contents() 21 | then: 22 | content == [] 23 | } 24 | 25 | def "Buffer has size 1 after adding a byte"() { 26 | when: 27 | byte value = 0x3a 28 | buffer.add(value) 29 | then: 30 | buffer.size() == 1 31 | } 32 | 33 | def "Buffer contents returns the data"() { 34 | when: 35 | byte value = 0x39 36 | buffer.add(value) 37 | then: 38 | buffer.contents() == [0x0039] 39 | } 40 | 41 | def "Buffer adds a byte of 0xff"() { 42 | when: 43 | byte value = 0xff as byte 44 | buffer.add(value) 45 | then: 46 | buffer.contents() == [0x00ff] 47 | } 48 | 49 | def "Buffer has size 2 after adding a short"() { 50 | when: 51 | buffer.add(0x393a as short) 52 | then: 53 | buffer.size() == 2 54 | } 55 | 56 | def "Buffer has size 4 after adding an int"() { 57 | when: 58 | buffer.add(0x393a3b3c) 59 | then: 60 | buffer.size() == 4 61 | } 62 | 63 | def "Buffer has size 8 after adding a long"() { 64 | when: 65 | buffer.add(0x393a3b3c as long) 66 | then: 67 | buffer.size() == 8 68 | } 69 | 70 | def "Buffer contents are in little endian format after adding a short"() { 71 | when: 72 | buffer.add(0x393a as short) 73 | then: 74 | buffer.contents() == ([0x003a, 0x0039] ) 75 | } 76 | 77 | def "Buffer contents are in little endian format after adding an int"() { 78 | when: 79 | buffer.add(0x393a3b3c) 80 | then: 81 | buffer.contents() == [0x003c, 0x003b, 0x003a, 0x0039] 82 | } 83 | 84 | def "Buffer contents are in little endian format after adding a long"() { 85 | when: 86 | buffer.add(0x393a3b3c3d3e3f40) 87 | then: 88 | buffer.contents() == [0x0040, 0x003f, 0x003e, 0x003d, 0x003c, 0x003b, 0x003a, 0x0039] 89 | } 90 | 91 | def "Buffer has size 3 after adding a 3 byte array"() { 92 | when: 93 | buffer.add([0x01, 0x02, 0x03] as byte[]) 94 | then: 95 | buffer.size() == 3 96 | } 97 | 98 | def "Buffer contents are in correct order after adding a 3 byte array"() { 99 | when: 100 | buffer.add([0x01, 0x02, 0x03] as byte[]) 101 | then: 102 | buffer.contents() == [0x0001, 0x0002, 0x0003] 103 | } 104 | 105 | def "Buffer can add another buffer"() { 106 | given: 107 | def other = new Buffer() 108 | and: 109 | other.add([0x39, 0x3a] as byte[]) 110 | and: 111 | buffer.add(0x38 as byte) 112 | when: 113 | buffer.add(other) 114 | then: 115 | buffer.contents() == [0x0038, 0x0039, 0x003a] 116 | } 117 | 118 | def "Adding an empty buffer has no impact"() { 119 | given: 120 | def other = new Buffer() 121 | and: 122 | buffer.add(0x38 as byte) 123 | when: 124 | buffer.add(other) 125 | then: 126 | buffer.contents() == [0x0038] 127 | } 128 | 129 | def "It fills with multiple copies of a byte"() { 130 | when: 131 | buffer.addByteCopies(0xaa as byte, 4) 132 | then: 133 | buffer.contents() == [0x00aa,0x00aa,0x00aa,0x00aa] 134 | } 135 | } -------------------------------------------------------------------------------- /src/test/groovy/FrameAddressTest.groovy: -------------------------------------------------------------------------------- 1 | import spock.lang.Specification 2 | 3 | 4 | class FrameAddressTest extends Specification { 5 | 6 | Buffer buffer 7 | 8 | def setup() { 9 | buffer = new Buffer() 10 | } 11 | 12 | def "It fills the buffer with an all devices frame address"() { 13 | when: 14 | def frameAddress = new FrameAddress(0L, true, true, 0x97 as byte) 15 | frameAddress.addToBuffer(buffer) 16 | def expected = ([ 17 | 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 18 | 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 19 | 0x0003, 20 | 0x0097 21 | ]) 22 | then: 23 | buffer.contents() == expected 24 | } 25 | 26 | def "It fills the buffer with an all devices frame address but no ack"() { 27 | when: 28 | def frameAddress = new FrameAddress(0L, false, true, 0x97 as byte) 29 | frameAddress.addToBuffer(buffer) 30 | def expected = ([ 31 | 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 32 | 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 33 | 0x0001, 34 | 0x0097 35 | ]) 36 | then: 37 | buffer.contents() == expected 38 | } 39 | 40 | def "It fills the buffer with an all devices frame address but no response"() { 41 | when: 42 | def frameAddress = new FrameAddress(0L, true, false, 0x97 as byte) 43 | frameAddress.addToBuffer(buffer) 44 | def expected = ([ 45 | 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 46 | 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 47 | 0x0002, 48 | 0x0097 49 | ]) 50 | then: 51 | buffer.contents() == expected 52 | } 53 | 54 | def "It fills the buffer with an all devices frame address but no ack or response"() { 55 | when: 56 | def frameAddress = new FrameAddress(0L, false, false, 0x97 as byte) 57 | frameAddress.addToBuffer(buffer) 58 | def expected = ([ 59 | 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 60 | 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 61 | 0x0000, 62 | 0x0097 63 | ]) 64 | then: 65 | buffer.contents() == expected 66 | } 67 | 68 | def "It fills the buffer with an all targeted frame address"() { 69 | when: 70 | def frameAddress = new FrameAddress(0x12345678ABCD, true, true, 0x97 as byte) 71 | frameAddress.addToBuffer(buffer) 72 | def expected = ([ 73 | 0x00cd, 0x00ab, 0x0078, 0x0056, 0x0034, 0x0012, 0x0000, 0x0000, 74 | 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 75 | 0x0003, 76 | 0x0097 77 | ]) 78 | then: 79 | buffer.contents() == expected 80 | } 81 | 82 | } -------------------------------------------------------------------------------- /src/test/groovy/ParserTest.groovy: -------------------------------------------------------------------------------- 1 | import spock.lang.Specification 2 | 3 | 4 | class ParserTest extends Specification { 5 | 6 | def "It creates a parser from a string and parses little-endian bytes"() { 7 | given: 8 | def parser = new Parser('thingy:2l') 9 | when: 10 | def result = parser.parse([0x30, 0x00] as List) 11 | then: 12 | result == [ 13 | thingy: 48, 14 | ] 15 | } 16 | 17 | def "It creates a parser from a string and parses big-endian bytes"() { 18 | given: 19 | def parser = new Parser('thingy:2b') 20 | when: 21 | def result = parser.parse([0x72, 0x73] as List) 22 | then: 23 | result == [ 24 | thingy: 0x7273, 25 | ] 26 | } 27 | 28 | def "It copes with unsigned longs"() { 29 | given: 30 | def parser = new Parser('test:1,thingy:4l') 31 | when: 32 | def result = parser.parse([0x10, 0xFC, 0xFD, 0xFE, 0xFF, 0xD0] as List) 33 | then: 34 | result == [ 35 | test: 0x10, 36 | thingy: 0xFFFEFDFC, 37 | remainder: [0xD0] 38 | ] 39 | } 40 | 41 | def "It creates a parser from a string and parses two shorts"() { 42 | given: 43 | def parser = new Parser('thingy:2l,thing2:2b') 44 | when: 45 | def result = parser.parse([0x72, 0x73, 0x74, 0x75] as List) 46 | then: 47 | result == [ 48 | thingy: 0x7372, 49 | thing2: 0x7475 50 | ] 51 | } 52 | 53 | 54 | def "It copes with an omitted type specifier"() { 55 | given: 56 | def parser = new Parser('thingy:2,thing2:2b') 57 | when: 58 | def result = parser.parse([0x72, 0x73, 0x74, 0x75] as List) 59 | then: 60 | result == [ 61 | thingy: 0x7372, 62 | thing2: 0x7475 63 | ] 64 | } 65 | 66 | def "It copes with an underscore in the name"() { 67 | given: 68 | def parser = new Parser('thing_1:2,thing_2:2b') 69 | when: 70 | def result = parser.parse([0x72, 0x73, 0x74, 0x75] as List) 71 | then: 72 | result == [ 73 | thing_1: 0x7372, 74 | thing_2: 0x7475 75 | ] 76 | } 77 | 78 | def "It creates a parser from a string and parses a byte buffer little-endian shorts"() { 79 | given: 80 | def parser = new Parser('thingy:2b,thing2:12a') 81 | when: 82 | def result = parser.parse([0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x74, 0x75, 0x76, 0x77, 0x74, 0x75, 0x76, 0x77] as List) 83 | then: 84 | result == [ 85 | thingy: 0x7273, 86 | thing2: [0x74, 0x75, 0x76, 0x77, 0x74, 0x75, 0x76, 0x77, 0x74, 0x75, 0x76, 0x77] 87 | ] 88 | } 89 | 90 | def "It appends the remainder"() { 91 | given: 92 | def parser = new Parser('thingy:2b') 93 | when: 94 | def result = parser.parse([0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x74, 0x75, 0x76, 0x77, 0x74, 0x75, 0x76, 0x77] as List) 95 | then: 96 | result == [ 97 | thingy : 0x7273, 98 | remainder: [0x74, 0x75, 0x76, 0x77, 0x74, 0x75, 0x76, 0x77, 0x74, 0x75, 0x76, 0x77] 99 | ] 100 | } 101 | 102 | def "It creates a string"() { 103 | given: 104 | def parser = new Parser('thingy:4s') 105 | when: 106 | def result = parser.parse([0x48, 0x45, 0x4C, 0x44] as List) 107 | then: 108 | result == [ 109 | thingy : 'HELD' 110 | ] 111 | } 112 | 113 | 114 | } -------------------------------------------------------------------------------- /src/test/groovy/ProtocolHeaderTest.groovy: -------------------------------------------------------------------------------- 1 | import spock.lang.Specification 2 | 3 | class ProtocolHeaderTest extends Specification { 4 | 5 | ProtocolHeader header 6 | Buffer buffer 7 | 8 | def setup() { 9 | buffer = new Buffer() 10 | header = new ProtocolHeader(12 as short) 11 | } 12 | 13 | def "Sets the type"() { 14 | when: 15 | header.fillBuffer(buffer) 16 | then: 17 | buffer.contents() == [ 18 | 0, 0, 0, 0, 19 | 0, 0, 0, 0, 20 | 12, 0, 21 | 0, 0, 22 | ] 23 | } 24 | } -------------------------------------------------------------------------------- /src/test/groovy/ShortFrameTest.groovy: -------------------------------------------------------------------------------- 1 | import spock.lang.Specification 2 | 3 | class ShortFrameTest extends Specification { 4 | 5 | def "Fills a buffer with target true"() { 6 | given: 7 | def shortFrame = new ShortFrame(true, 0xefbeadde as int) 8 | def buffer = new Buffer() 9 | when: 10 | shortFrame.fillBuffer(buffer) 11 | def expected = [0x00, 0x0034, 0x00de, 0x00ad, 0x00be, 0x00ef] 12 | then: 13 | buffer.contents() == expected 14 | } 15 | 16 | def "Fills a buffer with target false"() { 17 | given: 18 | def shortFrame = new ShortFrame(false, 0xdeadbeef as int) 19 | def buffer = new Buffer() 20 | when: 21 | shortFrame.fillBuffer(buffer) 22 | def expected = [0x00, 0x0014, 0x00ef, 0x00be, 0x00ad, 0x00de] 23 | then: 24 | buffer.contents() == expected 25 | } 26 | } --------------------------------------------------------------------------------