import groovy.json.JsonSlurper
import groovy.transform.Field
/**
*
* Copyright 2019 Robert Heyes. All Rights Reserved
*
* This software is free for Private Use. You may use and modify the software without distributing it.
* If you make a fork, and add new code, then you should create a pull request to add value, there is no
* guarantee that your pull request will be merged.
*
* You may not grant a sublicense to modify and distribute this software to third parties without permission
* from the copyright holder
* Software is provided without warranty and your use of it is at your own risk.
*
*/
@Field Integer extraProbesPerPass = 0
@Field Boolean wantBufferCaching = false // should probably remove this?
definition(
name: 'LIFX Master',
namespace: 'robheyes',
author: 'Robert Alan Heyes',
description: 'Provides for discovery and control of LIFX devices',
category: 'Discovery',
iconUrl: '',
iconX2Url: '',
iconX3Url: ''
)
preferences {
page(name: 'mainPage')
page(name: 'discoveryPage')
page(name: 'namedColorsPage')
page(name: 'testBedPage')
}
@SuppressWarnings("unused")
def mainPage() {
dynamicPage(name: "mainPage", title: "Options", install: true, uninstall: true) {
section {
input 'interCommandPause', 'number', defaultValue: 50, title: 'Time between commands (milliseconds)', submitOnChange: true
input 'maxPasses', 'number', title: 'Maximum number of passes', defaultValue: 2, submitOnChange: true
input 'refreshInterval', 'number', title: 'Discovery page refresh interval (seconds).
WARNING: high refresh rates may interfere with discovery.', defaultValue: 6, submitOnChange: true
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
input 'baseIpSegment', 'text', title: 'IP subnet(s)', description: 'e.g. 192.168.0 or 192.168.1, separate multiple subnets with commas', submitOnChange: true
input 'savePreferences', 'button', title: 'Save', submitOnChange: true
}
discoveryPageLink()
colorsPageLink()
testBedPageLink()
includeStyles()
}
}
def mainPageLink() {
section {
href(
name: 'Main page',
page: 'mainPage',
description: 'Back to main page'
)
}
}
@SuppressWarnings("unused")
def discoveryPage() {
dynamicPage(name: 'discoveryPage', title: 'Discovery', refreshInterval: refreshInterval()) {
section {
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"
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.'''
input 'discoverBtn', 'button', title: 'Discover devices'
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'
paragraph(
null == atomicState.scanPass ?
'' :
('DONE' == atomicState.scanPass) ?
'Scanning complete' :
"""Scanning your network for devices from subnets [${describeSubnets()}]
h3.pre {
background: #81BC00;
font-size: larger;
font-weight: bolder
}
h4.pre {
background: #81BC00;
font-size: larger
}
ul {
list-style-type: none;
}
ul.device-group {
background: #81BC00;
padding: 0;
}
ul.device {
background: #D9ECB1;
}
li.device-group {
font-weight: bold;
}
li.device {
font-weight: normal;
}
li.device-error {
font-weight: bold;
background: violet
}
button.hrefElem span.state-incomplete-text {
display: block
}
button.hrefElem span {
display: none
}
button.hrefElem br {
display: none
}
/* Progress bar - modified from https://css-tricks.com/examples/ProgressBars/ */
.meter {
height: 20px; /* Can be anything */
position: relative;
background: #D9ECB1;
-moz-border-radius: 5px;
-webkit-border-radius: 5px;
border-radius: 5px;
padding: 0px;
}
.meter > span {
display: block;
height: 100%;
border-top-right-radius: 2px;
border-bottom-right-radius: 2px;
border-top-left-radius: 5px;
border-bottom-left-radius: 5px;
background-color: #81BC00;
position: relative;
overflow: hidden;
text-align: center;
}
/$
}
String colorListHTML(String sortOrder) {
builder = new StringBuilder()
builder << ''
colorList(sortOrder).each {
builder << '''
'''
builder << ''
builder << "$it.name | "
builder << " | "
builder << '
'
}
builder << '
'
builder.toString()
}
private String discoveryTextKnownDevices() {
if ((atomicState.numDevices == null) || (0 == atomicState.numDevices)) {
return 'No devices known'
}
def deviceList = describeDevices() // don't inline this
// log.debug(deviceList)
"I have found ${atomicState.numDevices} useable LIFX devices so far: ${deviceList}"
}
private String describeDevices() {
def sorted = getKnownIps().sort { a, b -> (a.value.label as String).compareToIgnoreCase(b.value.label as String) }
def grouped = sorted.groupBy { it.value.group }
def builder = new StringBuilder()
builder << ''
grouped.each {
groupName, devices ->
builder << "- $groupName
"
builder << ''
devices.each {
ip, device ->
builder << (device.error ?
"- ${device.label} (${device.error})
"
: "- ${getDeviceNameLink(device)}
")
}
builder << '
'
}
builder << '
'
builder.toString()
}
private String getDeviceNameLink(device) {
def realDevice = getChildDevice(device.ip)
"$device.label"
}
Integer interCommandPauseMilliseconds(int pass = 1) {
(settings.interCommandPause ?: 40) + 10 * (pass - 1)
}
Integer maxScanPasses() {
settings.maxPasses ?: 2
}
Integer refreshInterval() {
settings.refreshInterval ?: 6
}
String deviceNamePrefix() {
settings.namePrefix ? settings.namePrefix + ' ' : ""
}
String ipSegment() {
settings.baseIpSegment
}
@SuppressWarnings("unused")
def updated() {
logDebug 'LIFX updating'
atomicState.subnets = null
initialize()
}
@SuppressWarnings("unused")
def installed() {
logDebug 'LIFX installed'
initialize()
}
@SuppressWarnings("unused")
def uninstalled() {
logDebug 'LIFX uninstalling - removing children'
removeChildren()
unsubscribe()
}
def initialize() {
updateKnownDevices()
}
private void updateKnownDevices() {
def knownDevices = knownDeviceLabels()
atomicState.numDevices = knownDevices.size()
}
@SuppressWarnings("unused")
def appButtonHandler(btn) {
if (btn == "discoverBtn") {
clearKnownIps()
clearDeviceDefinitions()
atomicState.packets = null
removeChildren()
discover()
} else if (btn == 'discoverNewBtn') {
clearKnownIpsWithErrors()
discoverNew()
} else if (btn == 'refreshExistingBtn') {
refreshExisting()
} else if (btn == 'clearCachesBtn') {
clearCachedDescriptors()
clearDeviceDefinitions()
clearBufferCache()
} else if (btn == 'testBtn') {
testColorMapBuilder()
} else if (btn == 'fetchBtn') {
loadFromMultizone()
}
}
def loadFromMultizone() {
if (!multizone) {
logDebug 'No multizone device'
return
}
}
def testColorMapBuilder() {
Map map = buildColorMaps(settings.colors)
def hsbkMaps = makeColorMaps map, settings.pattern as String
}
def setScanPass(pass) {
atomicState.scanPass = pass ?: null
}
def refresh() {
removeChildren()
discovery('discovery')
}
def discoverNew() {
endDiscovery()
discovery('discovery')
}
def refreshExisting() {
endDiscovery()
}
private void discover() {
logInfo("Discovery started")
String[] subnets = getSubnets()
if (0 == subnets.size()) {
log.warn "Can't discover the hub's subnet!"
return
}
clearCachedDescriptors()
int scanPasses = maxScanPasses()
Map queue = prepareQueue(makeVersionPacket())
subnets.each {
String subnet = it
1.upto(scanPasses) {
setScanPass(it)
scanNetwork queue, subnet, it
}
}
// sendEvent name: 'progress', value: 0
queue.size = queue.ipAddresses.size()
runInMillis(50, 'processQueue', [data: queue])
}
private String makeVersionPacket() {
makeDiscoveryPacketString typeOfMessage('DEVICE.GET_VERSION')
}
private void scanNetwork(Map queue, String subnet, Number pass) {
1.upto(pass + extraProbesPerPass) {
1.upto(254) {
def ipAddress = subnet + it
queue.ipAddresses << ipAddress
}
}
}
def handleOutstandingDevices(Map outstandingDevices, Map queue) {
logDebug("Processing outstanding devices")
queue.attempts++
if (queue.attempts > 5) {
return
}
outstandingDevices.each {
mac, data ->
queue.ipAddresses << data.ip
}
queue.size = queue.ipAddresses.size()
runInMillis(50, 'processQueue', [data: queue])
}
private Map prepareQueue(String packet, int delay = 20) {
[packet: packet, ipAddresses: [], delay: delay, attempts: 0]
}
@SuppressWarnings("unused")
private processQueue(Map queue) {
def oldPercent = calculateQueuePercentage(queue)
if (isQueueEmpty(queue)) {
endDiscovery()
return
}
def data = getNext(queue)
sendPacket data.ipAddress, data.packet
def newPercent = calculateQueuePercentage(queue)
if (oldPercent != newPercent) {
showProgress(newPercent)
}
runInMillis(queue.delay, 'processQueue', [data: queue])
}
private Map getNext(Map queue) {
String first = queue.ipAddresses.first()
queue.ipAddresses = queue.ipAddresses.tail()
[ipAddress: first, packet: queue.packet]
}
private isQueueEmpty(Map queue) {
queue.ipAddresses.isEmpty()
}
private int calculateQueuePercentage(Map queue) {
100 - (int) ((queue.ipAddresses.size() * 100) / queue.size as Long)
}
private void sendPacket(String ipAddress, String bytes) {
broadcast bytes, ipAddress
}
private void broadcast(String stringBytes, String ipAddress) {
sendHubCommand(
new hubitat.device.HubAction(
stringBytes,
hubitat.device.Protocol.LAN,
[
type : hubitat.device.HubAction.Type.LAN_TYPE_UDPCLIENT,
destinationAddress: ipAddress + ":56700",
encoding : hubitat.device.HubAction.Encoding.HEX_STRING,
ignoreWarning : true,
callback : "discoveryParse"
]
)
)
}
String discoveryType() {
return atomicState.discoveryType
}
private void discovery(String discoveryType) {
atomicState.discoveryType = discoveryType
atomicState.scanPass = null
updateKnownDevices()
clearDeviceDefinitions()
atomicState.progressPercent = 0
// def discoveryDevice = addChildDevice 'robheyes', 'LIFX Discovery', 'LIFX Discovery'
// subscribe discoveryDevice, 'lifxdiscovery.complete', removeDiscoveryDevice
// subscribe discoveryDevice, 'lifxdiscovery.outstanding', handleOutstandingDevices
// subscribe discoveryDevice, 'progress', progress
discover()
}
@SuppressWarnings("unused")
def progress(evt) {
def percent = evt.getIntegerValue()
showProgress(percent)
}
private void showProgress(int percent) {
Integer delta = percent - (atomicState.progressPercent ?: 0)
if (delta.abs() > 10) {
atomicState.progressPercent = percent
}
}
def getProgressPercentage() {
def percent = atomicState.progressPercent ?: 0
"$percent%"
}
void endDiscovery() {
logInfo 'Discovery complete'
// unsubscribe()
atomicState.scanPass = 'DONE'
try {
deleteChildDevice 'LIFX Discovery'
} catch (Exception e) {
// don't care, let it fail
}
}
void removeChildren() {
logInfo "Removing child devices"
childDevices.each {
if (it != null) {
deleteChildDevice it.deviceNetworkId
}
}
clearKnownIps()
updateKnownDevices()
}
@SuppressWarnings("unused")
def enableLevelChange(com.hubitat.app.DeviceWrapper device) {
sendEvent(device, [name: "cancelLevelChange", value: 'no', displayed: false])
}
Map deviceOnOff(String value, Boolean displayed, duration = 0) {
def actions = makeActions()
actions.commands << makeCommand('LIGHT.SET_POWER', [powerLevel: value == 'on' ? 65535 : 0, duration: duration * 1000])
actions.events << [name: "switch", value: value, displayed: displayed, data: [syncing: "false"]]
actions
}
Map deviceSetZones(com.hubitat.app.DeviceWrapper device, Map zoneMap, Boolean extMZ, Boolean displayed = true, String power = 'on') {
def actions = makeActions()
if (extMZ) {
actions.commands << makeCommand('MULTIZONE.SET_EXTENDED_COLOR_ZONES', zoneMap)
} else {
for (int i = 0; i < zoneMap.zone_count; i++) {
if (zoneMap.colors[i]) {
actions.commands << makeCommand('MULTIZONE.SET_COLOR_ZONES', [start_index: i, end_index: i, color: zoneMap.colors[i], duration: zoneMap.duration])
}
}
actions.commands << makeCommand('MULTIZONE.SET_COLOR_ZONES', [color: [:], apply: 2])
}
if (null != power && device.currentSwitch != power) {
def powerLevel = 'on' == power ? 65535 : 0
actions.commands << makeCommand('LIGHT.SET_POWER', [powerLevel: powerLevel, duration: zoneMap.duration * 1000])
actions.events << [name: "switch", value: power, displayed: displayed, data: [syncing: "false"]]
}
actions
}
Map deviceSetMultiZoneEffect(String effectType, Integer speed, String direction) {
def actions = makeActions()
def params = new int[8]
params[1] = direction == 'reverse' ? 0 : 1
actions.commands << makeCommand('MULTIZONE.SET_MULTIZONE_EFFECT', [instanceId: 5439, type: effectType == 'MOVE' ? 1 : 0, speed: effectType == 'OFF' ? 0 : speed * 1000, parameters: params])
actions
}
Map deviceSetTileEffect(String effectType, Integer speed, Integer palette_count, List