/**
* OmniLogic Smartapp
*
* Version: 1.0.1
* Copyright 2022 Maarten van Tjonger
*/
definition(
name: "OmniLogic",
namespace: "maartenvantjonger",
author: "Maarten van Tjonger",
description: "Hayward OmniLogic pool equipment integration",
category: "Convenience",
iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png",
iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png",
iconX3Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png"
)
preferences {
page(name: "mainPage", title: "OmniLogic settings", install: true, uninstall: true)
page(name: "loginPage", title: "OmniLogic account")
page(name: "loginResultPage", title: "OmniLogic account")
page(name: "devicePage", title: "OmniLogic devices")
page(name: "deviceResultPage", title: "OmniLogic devices")
page(name: "telemetryPage", title: "OmniLogic telemetry")
}
def installed() {
logMethod("installed")
initialize()
}
def uninstalled() {
logMethod("uninstalled")
uninitialize()
deleteDevicesExcept(null)
}
def updated() {
logMethod("updated")
uninitialize()
initialize()
}
def initialize() {
logMethod("initialize")
if (enableTelemetry) {
runEvery15Minutes(updateDeviceStatuses)
}
}
def uninitialize() {
logMethod("uninitialize")
unsubscribe()
unschedule()
}
def logMethod(method, message = null, arguments = null) {
logMethod(app, method, message, arguments)
}
def logMethod(context, method, message, arguments) {
def logMessage = "${context.getName()}.${method}()"
if (message) {
logMessage += " | ${message}"
}
if (arguments) {
def argumentsString = arguments.collect { argument ->
if (argument instanceof groovy.util.slurpersupport.GPathResult) {
// Serialize XML arguments
return groovy.xml.XmlUtil.serialize(argument)
}
return argument
}.join(", ")
logMessage += " | ${argumentsString}"
}
logDebug(logMessage)
}
def logDebug(message) {
if (!enableLogging || message == null) {
return
}
// Escape XML on hubitat to ensure correct rendering in UI
if (getPlatform() == "Hubitat") {
log.debug(groovy.xml.XmlUtil.escapeXml(message))
} else {
log.debug(message)
}
}
def mainPage() {
if (settings.username == null) {
return loginPage()
}
dynamicPage(name: "mainPage") {
section {
href "loginPage", title: "Account", description: "Change account settings"
href "devicePage", title: "Devices", description: "Choose pool equipment devices"
href "telemetryPage", title: "Telemetry", description: "View system status"
}
section("Settings") {
input name: "enableLogging", type: "bool", title: "Enable debug logging", defaultValue: false
input name: "enableTelemetry", type: "bool", title: "Enable periodic telemetry updates", defaultValue: true
}
}
}
def loginPage() {
return dynamicPage(name: "loginPage", nextPage: "loginResultPage") {
section("Enter your OmniLogic account credentials") {
input("username", "email", title: "Username", description: "")
input("password", "password", title: "Password", description: "")
input("mspId", "text", title: "MSP System ID", description: "The MSP (Main System Processor) System ID of your OmniLogic pool controller")
}
}
}
def loginResultPage() {
def resultText = "Login failed. Please try again."
def nextPage = "loginPage"
login(true) { success ->
if (success) {
resultText = "Login succeeded"
nextPage = "mainPage"
}
}
return dynamicPage(name: "loginResultPage", nextPage: nextPage) {
section {
paragraph resultText
}
}
}
def devicePage() {
try {
// Get currently installed child devices
settings.devicesToUse = childDevices*.deviceNetworkId
// Get available devices from OmniLogic
getAvailableDevices()
def availableDeviceNames = state.availableDevices.collectEntries { [it.key, "${it.value.name} (${it.value.driverName})"] }
if (availableDeviceNames?.size() > 0) {
return dynamicPage(name: "devicePage", nextPage: "deviceResultPage") {
section {
input(
name: "devicesToUse",
type: "enum",
title: "Select devices to use",
required: false,
multiple: true,
options: availableDeviceNames
)
}
}
}
} catch (e) {
logMethod("devicePage", "Error getting devices", [e])
}
return dynamicPage(name: "devicePage") {
section {
paragraph "Error getting devices"
}
}
}
def deviceResultPage() {
def updated = updateDevices()
return dynamicPage(name: "deviceResultPage", nextPage: "mainPage") {
section {
paragraph updated ? "Updated devices" : "Failed to create devices. Make sure all OmniLogic Device Handlers are installed"
}
}
}
def telemetryPage() {
return dynamicPage(name: "telemetryPage") {
try {
updateDeviceStatuses()
def telemetryData = getPlatform() == "Hubitat"
? groovy.xml.XmlUtil.escapeXml(state.telemetryData)
: state.telemetryData
section {
paragraph telemetryData ?: "No data"
}
} catch (e) {
logMethod("telemetryPage", "Error getting telemetry data", [e])
section {
paragraph "Error getting telemetry data"
}
}
}
}
def getTelemetryData(callback) {
logMethod("getTelemetryData")
// Cache telemetry data for 10 seconds
if (state.telemetryTimestamp != null && state.telemetryTimestamp + 10000 > now()) {
logMethod("getTelemetryData", "Returning cached telemetry data")
def telemetryData = new XmlSlurper().parseText(state.telemetryData)
callback(telemetryData)
return
}
performApiRequest("RequestTelemetryData") { response ->
if (response == null) {
return
}
state.telemetryTimestamp = now()
state.telemetryData = groovy.xml.XmlUtil.serialize(response)
logMethod("getTelemetryData", "Returning telemetry data")
callback(response)
}
}
def getMspConfig(callback) {
logMethod("getMspConfig")
// Cache MSP Config data for 10 minutes
if (state.mspConfigTimestamp != null && state.mspConfigTimestamp + 600000 > now()) {
logMethod("getMspConfig", "Returning cached MSP Config data")
def mspConfig = new XmlSlurper().parseText(state.mspConfig)
callback(mspConfig)
return
}
performApiRequest("RequestConfiguration") { response ->
if (response == null) {
return
}
state.mspConfigTimestamp = now()
state.mspConfig = groovy.xml.XmlUtil.serialize(response)
logMethod("getMspConfig", "Returning MSP Config data")
callback(response)
}
}
def getAvailableDevices() {
logMethod("getAvailableDevices")
getMspConfig { mspConfig ->
if (mspConfig == null) {
return
}
def availableDevices = [:]
// Parse available devices from MSP Config
def backyardNodes = mspConfig.Backyard
backyardNodes.Sensor.each { addTemperatureSensor(availableDevices, it) }
backyardNodes.Relay.each { addDevice(availableDevices, it, null, "OmniLogic Relay") }
def bowNodes = backyardNodes."Body-of-water"
bowNodes.Sensor.each { addTemperatureSensor(availableDevices, it) }
bowNodes.Filter.each { addFilter(availableDevices, it) }
bowNodes.Pump.each { addPump(availableDevices, it) }
bowNodes.Heater.each { addHeater(availableDevices, it) }
bowNodes.Chlorinator.each { addChlorinator(availableDevices, it) }
bowNodes.Relay.each { addDevice(availableDevices, it, null, "OmniLogic Relay") }
bowNodes."ColorLogic-Light".each { addDevice(availableDevices, it, null, "OmniLogic Light") }
state.availableDevices = availableDevices
logMethod("getAvailableDevices", "Available devices", [availableDevices])
}
}
def addTemperatureSensor(availableDevices, deviceDefinition) {
// Only add water or air temperature sensors
def sensorType = deviceDefinition.Type.text()
if (!sensorType.contains("SENSOR_WATER_TEMP") && !sensorType.contains("SENSOR_AIR_TEMP")) {
return
}
def locationDefinition = deviceDefinition.parent()
def locationId = locationDefinition."System-Id".text()
def omnilogicId = locationId
// Use MSP ID for Backyard Air Temperature Sensor so we can update it using telemetry data
if (omnilogicId == "0") {
omnilogicId = settings.mspId
}
def deviceId = getDeviceId(omnilogicId, null)
availableDevices[deviceId] = [
omnilogicId: omnilogicId,
name: locationDefinition.Name.text(),
driverName: "OmniLogic Temperature Sensor",
attributes: [
bowId: locationId,
sensorType: sensorType,
temperatureUnit: deviceDefinition.Units.text()
]
]
}
def addFilter(availableDevices, deviceDefinition) {
def driverName = deviceDefinition."Filter-Type".text() == "FMT_VARIABLE_SPEED_PUMP" ? "OmniLogic VSP" : "OmniLogic Pump"
addDevice(availableDevices, deviceDefinition, "Filter", driverName)
def bow = deviceDefinition.parent()
if (bow.Type == "BOW_SPA" && bow."Supports-Spillover" == "yes") {
addDevice(availableDevices, deviceDefinition, "Spillover", driverName, [isSpillover: 1, deviceIdSuffix: "s"])
}
}
def addPump(availableDevices, deviceDefinition) {
def driverName = deviceDefinition.Type.text() == "PMP_VARIABLE_SPEED_PUMP" ? "OmniLogic VSP" : "OmniLogic Pump"
addDevice(availableDevices, deviceDefinition, null, driverName)
}
def addHeater(availableDevices, deviceDefinition) {
def temperatureSensorDefinition = deviceDefinition.parent().Sensor.find { it.Type.text().contains("SENSOR_WATER_TEMP") }
def attributes = [
omnilogicHeaterId: deviceDefinition.Operation.find { it.name() == "Heater-Equipment" }."System-Id".text(),
minTemperature: deviceDefinition."Min-Settable-Water-Temp".text().toInteger(),
maxTemperature: deviceDefinition."Max-Settable-Water-Temp".text().toInteger(),
temperatureUnit: temperatureSensorDefinition.Units.text()
]
addDevice(availableDevices, deviceDefinition, "Heater", "OmniLogic Heater", attributes)
}
def addChlorinator(availableDevices, deviceDefinition) {
def cellTypes = [
"CELL_TYPE_T3": 1,
"CELL_TYPE_T5": 2,
"CELL_TYPE_T9": 3,
"CELL_TYPE_T15": 4
]
def cellType = cellTypes[deviceDefinition."Cell-Type".text()] ?: 4
addDevice(availableDevices, deviceDefinition, "Chlorinator", "OmniLogic Chlorinator", [cellType: cellType])
addDevice(availableDevices, deviceDefinition, "Super Chlorinator", "OmniLogic Super Chlorinator", [deviceIdSuffix: "s"])
}
def addDevice(availableDevices, deviceDefinition, name, driverName, attributes = [:]) {
def bowDefinition = deviceDefinition.parent()
attributes.bowId = bowDefinition."System-Id".text()
attributes.bowType = bowDefinition.Type.text() == "BOW_SPA" ? 1 : 0
def omnilogicId = deviceDefinition."System-Id".text()
def deviceId = getDeviceId(omnilogicId, attributes.deviceIdSuffix)
availableDevices[deviceId] = [
omnilogicId: omnilogicId,
name: "${bowDefinition.Name.text()} ${name ?: deviceDefinition.Name.text()}",
driverName: driverName,
attributes: attributes
]
if (attributes != null) {
availableDevices[deviceId].attributes.putAll(attributes)
}
}
def getDeviceId(omnilogicId, deviceIdSuffix) {
return "omnilogic-${omnilogicId}${deviceIdSuffix ?: ""}"
}
def createDevice(omnilogicId, name, driverName, attributes) {
logMethod("createDevice", "Attributes", [omnilogicId, name, driverName, attributes])
def deviceId = getDeviceId(omnilogicId, attributes?.deviceIdSuffix)
def childDevice = getChildDevice(deviceId)
if (childDevice == null) {
childDevice = addChildDevice("maartenvantjonger", driverName, deviceId, null, [name: name, completedSetup: true])
childDevice.initialize(omnilogicId, attributes)
}
return childDevice
}
def updateDevices() {
logMethod("updateDevices")
// Delete devices that were unselected
deleteDevicesExcept(settings.devicesToUse)
// Create devices that were selected
def devicesToCreate = settings.devicesToUse?.findAll { getChildDevice(it) == null && state.availableDevices[it] != null }
if (devicesToCreate?.size() > 0) {
try {
devicesToCreate.each { deviceId ->
def device = state.availableDevices[deviceId]
createDevice(device.omnilogicId, device.name, device.driverName, device.attributes)
}
updateDeviceStatuses()
} catch (e) {
logMethod("updateDevices", "Error updating devices", [e])
return false
}
}
return true
}
def updateDeviceStatuses() {
logMethod("updateDeviceStatuses")
getTelemetryData { telemetryData ->
childDevices.each { device ->
def omnilogicId = device.currentValue("omnilogicId").toInteger()
def deviceStatus = telemetryData.children().find { it.@systemId?.text().toInteger() == omnilogicId }
device.parseStatus(deviceStatus, telemetryData)
}
}
}
def deleteDevicesExcept(deviceIds) {
logMethod("deleteDevicesExcept", "Attributes", [deviceIds])
childDevices
.findAll { deviceIds == null || !deviceIds.contains(it.deviceNetworkId) }
.each {
try {
deleteChildDevice(it.deviceNetworkId)
logMethod("deleteDevicesExcept", "Deleted device", [it.deviceNetworkId])
} catch (e) {
logMethod("deleteDevicesExcept", "Error deleting device", [it.deviceNetworkId, e])
}
}
}
def login(force, callback) {
logMethod("login", "Arguments", [force])
if (!force && state.session?.expiration > now()) {
logMethod("login", "Current token is still valid")
return callback(true)
}
state.session = [
token: null,
userId: null,
expiration: 0
]
def parameters = [
[name: "UserName", value: settings.username],
[name: "Password", value: settings.password]
]
try {
performApiRequest("Login", parameters) { response ->
def responseParameters = response?.Parameters?.Parameter
if (responseParameters?.find { it.@name == "Status" }.text() != "0") {
logMethod("login", "Failed")
return callback(false)
}
logMethod("login", "Succeeded")
state.session.token = responseParameters.find { it.@name == "Token" }.text()
state.session.userId = responseParameters.find { it.@name == "UserID" }.text()
state.session.expiration = now() + 12 * 60 * 60 * 1000 // 12 hours
return callback(true)
}
} catch (e) {
logMethod("login", "Error logging in", [e])
return callback(false)
}
}
def performApiRequest(name, parameters = [], callback) {
logMethod("performApiRequest", "Arguments", [name, parameters])
// Perform login sequence for API requests other than Login itself,
// to make sure we have a valid token
if (name != "Login") {
login(false) { success ->
if (!success) {
return
}
}
parameters.add(0, [name: "Token", value: state.session.token])
parameters.add(1, [name: "MspSystemID", dataType: "int", value: settings.mspId])
}
// Perform API request
def requestXml = formatApiRequest(name, parameters)
logMethod("performApiRequest", "Request", [requestXml])
httpPost([
uri: "https://www.haywardomnilogic.com/MobileInterface/MobileInterface.ashx",
contentType: "text/xml",
body: requestXml
]) { response ->
logMethod("performApiRequest", "Response", [response.status, response.data])
if (response.status == 200 && response.data) {
return callback(response.data)
}
return callback(null)
}
}
def formatApiRequest(name, parameters) {
def parameterXml = parameters?.collect {
"${it.value}\n"
}.join().trim()
return """
${name}
${parameterXml}
""".trim()
}
def getPlatform() {
physicalgraph?.device?.HubAction ? "SmartThings" : "Hubitat"
}