Created
July 10, 2022 23:00
-
-
Save bkrn/1c613361b7d4239b63bc24be5d3444e0 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import groovy.transform.Field | |
definition( | |
name: 'Eiger', | |
namespace: 'eiger', | |
author: 'Aaron Di Silvestro', | |
description: 'Event initiated general engine for rules', | |
iconUrl: '', | |
iconX2Url: '', | |
iconX3Url: '' | |
) | |
@Field static Map<String, Object> rules = null | |
public static String version() { return 'v???' } | |
preferences | |
{ | |
page(name: 'mainPage') | |
} | |
// Save current rule etag globally | |
Void cacheRule(Map<String, String> headers) { | |
if (headers.containsKey('ETag')) { | |
etag = headers['ETag'] | |
} else { | |
etag = null | |
} | |
} | |
Void saveRules(response, _) { | |
if (response.getStatus() == 200) { | |
rules = parseJson(response.getData()) | |
rules['etag'] = response.getHeaders()['ETag'] | |
log.debug 'Got new rules' | |
createDevices() | |
} else if (response.getStatus() == 304) { | |
log.debug 'Rules have not changed' | |
} else { | |
log.debug 'Error getting rules' | |
} | |
runIn(300, 'loadRules') | |
} | |
Boolean notDeviceExists(name) { | |
return allDevices().find { it.name == name || it.label == name } == null | |
} | |
// Create an id for a new child device from the lowest available integer | |
String newDeviceId() { | |
children = getChildDevices() | |
List<Integer> deviceIds = [] | |
for (child in children) { | |
deviceIds.add(child.getDeviceNetworkId().split('-')[-1] as int) | |
} | |
deviceIds = deviceIds.sort() | |
c = 0 | |
for (ix in deviceIds) { | |
if (ix != c) { | |
return String.format('eiger-child-%d', c) | |
} | |
c++ | |
} | |
return String.format('eiger-child-%d', c) | |
} | |
// Remove all child devices that are no longer in the rules object | |
Void cleanDevices() { | |
children = getChildDevices() | |
for (child in children) { | |
if (rules.groups.find { it.name == child.name } == null && rules.rules.find { it.name == child.name } == null) { | |
deleteChildDevice(child.getDeviceNetworkId()) | |
} else if (child.getTypeName() != 'Virtual Dimmer') { | |
// **DRAGONS** | |
// This means that the vitual device lacks the | |
// dimmer capability and we should delete and start over | |
// removes it from other apps like Alexa :( | |
log.info "Reset device ${child.name}/${child.label} to make it a dimmer" | |
deleteChildDevice(child.getDeviceNetworkId()) | |
} | |
} | |
} | |
// Given a list of device specifications, create any that do not exist as children | |
Void _createDevices(List<Object> devices) { | |
for (device in devices) { | |
if (notDeviceExists(device.name)) { | |
addChildDevice('hubitat', 'Virtual Dimmer', newDeviceId(), [ | |
isComponent: true, | |
name: device.name, | |
label: device.name | |
]) | |
}} | |
} | |
// Create any devices specified by rules that do not currently exist | |
Void createDevices() { | |
cleanDevices() | |
_createDevices(rules.groups) | |
_createDevices(rules.rules) | |
subscribeToDevices() | |
} | |
// Fetch rules from github and call saveRules with response if etag is updated | |
def loadRules() { | |
url = 'https://gist.githubusercontent.com/bkrn/e787932996ffe0a856dcd8226e38f321/raw/rules.json' | |
if (rules != null) { | |
asynchttpGet('saveRules', [uri: url, headers: ['If-None-Match': rules['etag']]]) | |
} else { | |
asynchttpGet('saveRules', [uri: url]) | |
} | |
} | |
def mainPage() { | |
dynamicPage(name: '', title: '', install: true, uninstall: true, refreshInterval:0) | |
{ | |
section("<h2>${ app.label }</h2>") { } | |
if (state?.serverInstalled == null || state.serverInstalled == false) { | |
section("<b style=\"color:green\">${app.label} Installed!</b>") | |
{ | |
paragraph "Click <i>Done</i> to save then return to ${app.label} to continue." | |
} | |
return | |
} | |
section(getFormat('title', " ${ app.label }")) { } | |
section('') | |
{ | |
input 'externalDevices', 'capability.*', title: 'Select Devices', multiple: true, required: true | |
} | |
section('') | |
{ | |
input 'logEnable', 'bool', title: 'Enable Debug Logging?', required: false, defaultValue: true | |
} | |
display() | |
} | |
} | |
/* | |
installed | |
Purpose: Standard install function. | |
Notes: Doesn't do much. | |
*/ | |
def installed() { | |
log.info 'installed' | |
state.serverInstalled = true | |
initialize() | |
} | |
// Given a group from user rules | |
// Returns all devices inside it and any nested groups | |
// breaks cycles | |
List<Object> getGroupDevices(group) { | |
List<Object> groupDevices = [] | |
List<String> todo = group['devices'].clone() | |
Map<String, Boolean> cycleCache = [:] | |
while (!todo.isEmpty()) { | |
deviceName = todo.pop() | |
if (cycleCache[deviceName] == true) { | |
continue | |
} | |
device = allDevices().find { d -> d.getLabel() == deviceName } | |
if (!device) { | |
log.debug String.format('Device Label "%s" was not found', deviceName) | |
continue | |
} | |
for (subGroup in rules['groups'].findAll { d -> d['name'] == deviceName }) { | |
todo.addAll(subGroup['devices']) | |
} | |
cycleCache[deviceName] = true | |
// **DRAGONS** | |
// Right now we do not add App manufactured children | |
// (which represent groups) | |
// since, if we turn them on/off, we end up triggering | |
// their groups again | |
if (device.getParentAppId() != app.getId()) { | |
groupDevices.add(device) | |
} | |
} | |
return groupDevices | |
} | |
Void runGroup(action, root, eventName) { | |
log.debug "Eiger Group: ${root.name}, ${eventName} -> ${action}" | |
for (device in getGroupDevices(root)) { | |
log.debug "Eiger Group Member: ${device.label}, ${eventName} -> ${action}" | |
log.debug "Capabilities ${device.getCapabilities()}" | |
if (eventName == 'switch' && device.hasCapability('Switch')) { | |
if (action == 'on') { device.on() } | |
if (action == 'off') { device.off() } | |
} | |
// Hue dimmer buttons for on/off | |
if (eventName == 'pushed' && device.hasCapability('Switch')) { | |
if (action == '1') { device.on() } | |
if (action == '4') { device.off() } | |
} | |
if (eventName == 'level' && device.hasCapability('SwitchLevel')) { | |
device.setLevel(action as int, 2) | |
} | |
} | |
} | |
Boolean overidden(cache, deviceName, command) { | |
return ((cache[deviceName] == 'off' && command == 'on') || | |
(cache[deviceName] == 'on' && command == 'off') || | |
(cache[deviceName] == command)) | |
} | |
List<Object> populateRule(rule) { | |
List<Object> actions = [] | |
Map<String, Object> cached = [:] | |
for (action in rule['commands']) { | |
for (device in externalDevices.findAll { it.getLabel() == action.device }) { | |
if (overidden(cached, action.device, action.command)) { | |
break | |
} | |
actions.add([ | |
device: device, | |
command: action.command, | |
arg: action.arg, | |
]) | |
cached[action.device] = action.command | |
break | |
} | |
for (group in rules['groups']) { | |
if (action.device == group['name']) { | |
for (device in getGroupDevices(group)) { | |
if (!overidden(cached, device.getLabel(), action.command)) { | |
actions.add([ | |
device: device, | |
command: action.command, | |
arg: action.arg, | |
]) | |
cached[device.getLabel()] = action.command | |
} | |
} | |
} | |
} | |
} | |
log.debug cached | |
return actions | |
} | |
Void runRule(rule) { | |
log.debug String.format('Eiger Rule: %s', rule.name) | |
for (action in populateRule(rule)) { | |
log.debug action | |
if (action['command'] == 'on') { action.device.on() } | |
if (action['command'] == 'off') { action.device.off() } | |
if (action['command'] == 'color') { action.device.setColor(action.arg) } | |
if (action['command'] == 'level') { action.device.setLevel(action.arg) } | |
} | |
} | |
Boolean ruleMatchesEvent(event, rule) { | |
if (rule['name'] != event.getDevice().label) { | |
return false | |
} | |
if (rule.hasProperty('unit') && event.unit != rule['unit']) { | |
return false | |
} | |
if (rule.hasProperty('comparator')) { | |
if (rule['comparator'] == '>') { return (event.value as float) > action } | |
if (rule['comparator'] == '<') { return (event.value as float) < action } | |
} | |
return rule['action'] == event.value | |
} | |
def handleEvent(event) { | |
String action = event.value | |
String target = event.getDevice().label | |
String eventName = event.name | |
log.debug String.format('Eiger Event: %s %s %s', target, action, eventName) | |
for (rule in rules['rules']) { | |
Boolean ranRule = false | |
if (ruleMatchesEvent(event, rule)) { | |
runRule(rule) | |
ranRule = true | |
} | |
if (ranRule) { | |
return | |
} | |
} | |
for (group in rules['groups']) { | |
if (target == group['name']) { | |
runGroup(action, group, eventName) | |
} | |
} | |
} | |
def subscribeToDevices() { | |
log.info 'Subscribed to devices' | |
unsubscribe('handleEvent') | |
for (device in allDevices()) { | |
subscribe(device, 'handleEvent', [filterEvents: false]) | |
} | |
} | |
/* | |
updated | |
Purpose: Standard update function. | |
Notes: Still doesn't do much. | |
*/ | |
def updated() { | |
log.info 'updated run' | |
if (debugOutput) runIn(1800, logsOff) //disable debug logs after 30 min | |
initialize() | |
} | |
List<Object> allDevices() { | |
List<Object> devices = getChildDevices() + externalDevices | |
return devices | |
} | |
/* | |
initialize | |
Purpose: Initialize the server instance. | |
Notes: Does it all | |
*/ | |
def initialize() { | |
log.info 'initialize' | |
if (logEnable) log.debug 'Debugs are: ' + logEnable | |
unschedule() | |
loadRules() | |
subscribeToDevices() | |
} | |
/* | |
uninstalled | |
Purpose: uninstall the app. | |
Notes: Does it all | |
*/ | |
def uninstalled() { | |
log.info 'uninstalled' | |
} | |
/* | |
display | |
Purpose: Displays the title/copyright/version info | |
Notes: Not very exciting. | |
*/ | |
def display() { | |
section { | |
paragraph getFormat('line') | |
paragraph "<div style='color:#1A77C9;text-align:center;font-weight:small;font-size:9px'>Developed by: Aaron Di Silvestro<br/>Version Status: $state.Status<br>Current Version: ${version()} - ${thisCopyright}</div>" | |
} | |
} | |
/* | |
getFormat | |
Purpose: Formats of the title/copyright/version info for consistency | |
Notes: Not very exciting. | |
*/ | |
def getFormat(type, myText='') { | |
if (type == 'header-green') return "<div style='color:#ffffff;font-weight: bold;background-color:#81BC00;border: 1px solid;box-shadow: 2px 3px #A9A9A9'>${myText}</div>" | |
if (type == 'line') return "\n<hr style='background-color:#1A77C9; height: 1px; border: 0;'></hr>" | |
if (type == 'title') return "<h2 style='color:#1A77C9;font-weight: bold'>${myText}</h2>" | |
} | |
/* | |
logsOff | |
Purpose: Disable debug logs | |
Notes: Not very exciting. Called by a 'runin' timer, typically 30 min. | |
*/ | |
def logsOff() { | |
log.warn 'debug logging disabled...' | |
app?.updateSetting('logEnable', [value:'false', type:'bool']) | |
} | |
def getThisCopyright() { '© 2020 Aaron Di Silvestro' } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment